From 9f93026cfae6dab7f6acc181032c9b7e3d38350e Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 2 Aug 2026 13:02:37 -0700 Subject: [PATCH 1/8] Add finance charting surface --- docs/quant-finance-roadmap.md | 487 ++++++ examples/echarts.ipynb | 116 ++ js/src/00_header.ts | 1 + js/src/40_gl.ts | 56 + js/src/50_chartview.ts | 328 +++- js/src/52_tooltip.ts | 20 + js/src/54_kernel.ts | 5 + js/src/55_marks.ts | 30 + js/src/57_layers.ts | 891 +++++++++++ js/src/60_entries.ts | 3 +- python/xy/__init__.py | 117 ++ python/xy/_figure.py | 4 + python/xy/_payload.py | 62 + python/xy/_trace.py | 6 + python/xy/components.py | 149 ++ python/xy/finance.py | 2539 +++++++++++++++++++++++++++++++ python/xy/marks.py | 129 ++ spec/api/chart-kind-contract.md | 20 + spec/api/chart-roadmap.md | 9 + tests/test_api_parity.py | 15 +- tests/test_finance.py | 764 ++++++++++ tests/test_type_surface.py | 4 + 22 files changed, 5752 insertions(+), 3 deletions(-) create mode 100644 docs/quant-finance-roadmap.md create mode 100644 examples/echarts.ipynb create mode 100644 js/src/57_layers.ts create mode 100644 python/xy/finance.py create mode 100644 tests/test_finance.py diff --git a/docs/quant-finance-roadmap.md b/docs/quant-finance-roadmap.md new file mode 100644 index 00000000..1fcd2479 --- /dev/null +++ b/docs/quant-finance-roadmap.md @@ -0,0 +1,487 @@ +# Quant Finance Roadmap + +This document is the API and implementation plan for making xy a +production-grade quant finance charting surface. The goal is not to clone one +screen of TradingView. The goal is to support the same class of workflow: +high-performance OHLC rendering, composable studies, user-authored drawings, +forecasting/risk tools, volume tools, chart patterns, and application-level +customization through Python and Reflex-style components. + +## Reference Surface + +TradingView's drawing tools split the relevant finance surface into these +families: + +| Family | Tools to cover | Product meaning | +|---|---|---| +| Chart patterns | XABCD, ABCD, triangle, three drives, head and shoulders, Elliott waves, cyclic lines, time cycles, sine line | Manual pattern markup first; optional detection later. | +| Forecasting | Long position, short position, position forecast, bars pattern, ghost feed, sector | Trade planning, scenario projection, and visual comparison to prior price action. | +| Volume based measures | Anchored VWAP, fixed range volume profile, anchored volume profile | Volume-weighted price and support/resistance analysis over anchored ranges. | +| Measurement | Price range, date range, date and price range | Fast readouts for price, percentage, bars, duration, and ticks. | +| Supporting tools | Magnet/snap, keep drawing, lock/hide drawings, visibility by interval, object tree/templates | The difference between demo drawings and a real trading workstation. | + +Sources used for this plan: + +- [TradingView drawing tools available](https://www.tradingview.com/support/solutions/43000703396-drawing-tools-available-on-tradingview/) +- [Long and short position calculations](https://www.tradingview.com/support/solutions/43000475660-how-to-use-long-and-short-position-drawing-tools/) +- [Position forecast drawing tool](https://www.tradingview.com/support/solutions/43000517004-position-forecast-drawing-tool/) +- [Bar pattern drawing tool](https://www.tradingview.com/support/solutions/43000517006-bar-pattern-drawing-tool/) +- [Ghost feed drawing tool](https://www.tradingview.com/support/solutions/43000748168-ghost-feed-drawing-tool/) +- [Sector drawing tool](https://www.tradingview.com/support/solutions/43000516995-sector-drawing-tool/) +- [Anchored VWAP drawing tool](https://www.tradingview.com/support/solutions/43000669764-anchored-vwap-drawing-tool/) +- [Fixed range volume profile](https://www.tradingview.com/support/solutions/43000707985-fixed-range-volume-profile-drawing-tool/) +- [Anchored volume profile](https://www.tradingview.com/support/solutions/43000707989-anchored-volume-profile-drawing-tool/) +- [XABCD pattern drawing tool](https://www.tradingview.com/support/solutions/43000569909-xabcd-pattern-drawing-tool/) +- [ABCD pattern drawing tool](https://www.tradingview.com/support/solutions/43000570202-abcd-pattern-drawing-tool/) + +## Competitive Position + +The finance goal is not just to add candlesticks. The goal is to become the +best Python-native foundation for high-performance, application-controlled +finance charts. The current branch is ahead of generic Python plotting +libraries in architecture and finance-overlay ambition, but it should not yet be +marketed as beating the whole finance charting ecosystem. + +Honest current claim: + +> XY is building a TradingView-class finance surface from Python, backed +> by WebGL, binary transport, Rust/native kernels, composable finance layers, and +> Reflex-controlled state. The current finance branch already covers the core +> API shape and several advanced overlays, but finance-specific performance and +> product-maturity claims still need dedicated benchmarks and UX hardening. + +Competitive read: + +| Competitor | What they are strong at | XY position | +|---|---|---| +| Plotly | Mature Python API, candlestick/OHLC traces, range slider, annotations, Dash ecosystem. | XY should beat Plotly on large-data payload/rendering architecture, but not yet on docs, maturity, or finance UX breadth. | +| mplfinance | Purpose-built static financial charts, volume, moving averages, Renko, point-and-figure, and report/backtest workflows. | XY should beat mplfinance for interactive WebGL finance apps; mplfinance remains stronger for mature static finance plotting. | +| Lightweight Charts Python | Trading-oriented browser UI, realtime updates, crosshair, drawings, subcharts, and TradingView-style behavior through the Lightweight Charts engine. | This is the closest UX benchmark. XY needs editable drawings, crosshair/readouts, streaming, and state persistence before claiming parity. | +| Highcharts Stock / Highcharts for Python | Mature stock navigator, range controls, data grouping, accessibility/exporting, and a deep technical-indicator surface. | XY can aim for a more Python-native and high-performance open foundation, but Highcharts is far ahead in stock-chart product completeness. | +| Bokeh, Altair, pyecharts/ECharts | Broad interactive or declarative plotting with candlestick examples and useful ecosystem features. | XY can beat these for finance-specific API cohesion and large-data architecture once the finance workflow is hardened. | + +Where XY can credibly claim advantage first: + +- Large interactive OHLCV and overlay workloads where binary payloads, WebGL2, + Rust/native kernels, and view-dependent LOD keep browser work bounded. +- Python-native composition: `finance_chart(...)` with independent marks, + studies, drawings, and tool state instead of an overloaded candlestick API. +- App-level control through Reflex: chart state, drawing state, custom + tooltips, and user workflows should be controlled from Python application + state rather than trapped inside a private chart widget. +- TradingView-style overlay breadth for Python users: long/short risk boxes, + anchored VWAP, fixed/anchored volume profiles, bars pattern, ghost feed, + sectors, oscillators, and future pattern tools. + +Claims to avoid until measured: + +- Do not claim "faster than Plotly for finance charts" until OHLC-specific + payload, first-render, pan/zoom, streaming, and memory benchmarks exist. +- Do not claim "best finance charts in Python" until range selectors, session + axes, crosshair readouts, editable drawings, persistence, streaming, and + multi-pane workflows are production-ready. +- Do not compare static libraries and interactive browser libraries as one + blended category; benchmark static chart-to-pixels and interactive TTFR/latency + separately. + +Required finance benchmark suite: + +- OHLC payload build time and bytes for 10k, 100k, and 1M candles. +- First render in headless Chrome for candlestick only, candlestick plus volume, + candlestick plus overlays, oscillator panes, and volume profiles. +- Pan/zoom latency and frame stability, including OHLC aggregation at different + viewport widths. +- Streaming append/update latency for new bars and last-bar replacement. +- Browser memory and Python memory for large OHLCV, studies, and drawings. +- Competitor rows for Plotly, mplfinance, Lightweight Charts Python, Highcharts + Stock where licensing permits, Bokeh, and pyecharts/ECharts. + +## Tier 1 Build Order + +Build the quant surface 2D-first. The goal is to get the most common trading, +backtesting, and portfolio-analysis views working as composable primitives +before expanding into the long tail of finance tools. + +| Priority | Surface | Current status | Next implementation work | +|---|---|---|---| +| 1 | Candlestick / OHLC / line / area price base layer | Candlestick, OHLC, line, and area marks exist; area uses the line decimation path and can fill to the plot bottom or a numeric baseline. | Add candle ordinal/session spacing and richer OHLC tooltip payloads. | +| 2 | Volume subpanel synced beneath price | `volume_bars(source=..., pane="volume")` now materializes OHLCV volume and renders in a synced lower canvas pane beneath price. | Add volume hover/readouts and richer volume scaling/options. | +| 3 | TA overlays and oscillator subpanels | SMA/EMA moving averages, Bollinger bands, cumulative VWAP, and anchored VWAP now compute on the Python side and render as on-price line traces. RSI, MACD, and stochastic now materialize from OHLC sources and render in stacked synced oscillator panes with pane-local y scales. | Add oscillator hover/readouts, configurable pane heights, and native kernels for study computation. | +| 4 | Equity/PnL curve plus drawdown | `performance_chart(...)` and `equity_drawdown(...)` now render the equity/PnL curve in the top pane and drawdown in a synced lower pane, backed by Python reference helpers for equity, returns, drawdown arrays, peak/drawdown-low/recovery, and max drawdown. | Add performance hover/readouts, configurable pane sizing, and richer absolute-vs-percent drawdown formatting. | +| 5 | Returns distribution / histogram with VaR/CVaR markers | `returns_distribution_chart(...)` and `returns_distribution(...)` now render a histogram with styleable VaR/CVaR vertical marker lines, backed by Python reference helpers for histogram bins and historical risk metrics. | Add richer hover/readouts, distribution comparison overlays, and more risk marker variants. | + +## Core API Decision + +Do not add these features as kwargs on `candlestick()`. + +`candlestick()` should stay a fast OHLC mark. Forecasts, risk boxes, anchored +VWAP, volume profiles, and patterns should be separate components layered on top +of a composed chart. That keeps the API clean, lets the same tools work with +OHLC bars or future finance marks, and makes it possible to add multiple +overlays without a single overloaded candlestick constructor. + +The finance stack should have four object types: + +| Type | Examples | Render/data behavior | +|---|---|---| +| `Mark` | candlestick, OHLC, volume bars, line, scatter | Owns data columns and participates in range/tier decisions. | +| `Study` | SMA, EMA, VWAP, Bollinger, anchored VWAP | Computes from one or more source marks and renders as marks. | +| `Drawing` | trendline, sector, position forecast, bars pattern, patterns | User or Python authored anchors plus derived geometry. | +| `ToolState` | active tool, selected drawing, lock/hide/snap, templates | App/editor state, not a data series. | + +In the target component API, finance charts should feel like Reflex/Recharts +composition. This sketch is the desired surface, not a claim that each function +exists today: + +```python +import xy as fc + +fc.finance_chart( + fc.candlestick( + x="time", + open="open", + high="high", + low="low", + close="close", + volume="volume", + data=ohlcv, + id="price", + ), + fc.volume_bars(source="price", pane="volume"), + fc.moving_average(source="price", value="close", window=20, id="sma20"), + fc.anchored_vwap(source="price", anchor=("2026-01-02", 184.10)), + fc.long_position( + source="price", + entry=("2026-02-03", 191.20), + stop=184.50, + target=209.00, + end="2026-03-01", + account_size=100_000, + risk=0.01, + instrument=fc.instrument(tick_size=0.01, point_value=1.0, lot_size=1.0), + ), + fc.xabcd_pattern( + points=[ + ("2026-01-05", 180.0), + ("2026-01-19", 205.0), + ("2026-02-02", 190.0), + ("2026-02-18", 214.0), + ("2026-03-04", 196.0), + ], + validate="gartley", + ), + fc.x_axis(type_="time", session="us_equities"), + fc.y_axis(side="right", scale="linear"), + fc.finance_tools( + active="crosshair", + snap="ohlc", + editable=True, + on_change=handle_drawing_change, + ), +) +``` + +The target fluent API can mirror this without becoming the primary design +target: + +```python +fig = ( + fc.Figure() + .candlestick(time, open_, high, low, close, volume=volume, id="price") + .add(fc.LongPosition(entry=191.20, stop=184.50, target=209.00, source="price")) + .add(fc.AnchoredVWAP(anchor=anchor, source="price")) +) +``` + +## Spec Model + +The wire spec should remain data-light. Drawings and studies should ship as +small JSON declarations plus binary geometry only when they need computed +arrays. + +```json +{ + "tools": { + "snap": "ohlc", + "editable": true, + "selected": "risk-1" + }, + "layers": [ + { + "id": "risk-1", + "role": "drawing", + "kind": "long_position", + "source": "price", + "anchors": { + "entry": {"x": "2026-02-03", "y": 191.2}, + "stop": {"y": 184.5}, + "target": {"y": 209.0}, + "end": {"x": "2026-03-01"} + }, + "instrument": {"tick_size": 0.01, "point_value": 1.0, "lot_size": 1.0}, + "risk": {"account_size": 100000, "amount": 0.01, "mode": "fraction"} + } + ] +} +``` + +The client resolves layer geometry against the current axis transform. The +kernel computes only the parts that require data access: anchored VWAP, volume +profiles, indicator values, pattern detection, and any sampled/decimated +forecast geometry. + +## Coordinate And Interaction Model + +Finance tools need a richer coordinate system than basic x/y traces: + +| Coordinate kind | Use cases | +|---|---| +| `data` | Exact timestamp/price anchors. | +| `bar` | Pattern points, bars pattern copies, duration handles. | +| `price` | Horizontal stop/target/entry levels. | +| `pane` | Volume profile histograms and pane-local overlays. | +| `screen` | Labels, handles, drag affordances, hover cards. | + +Required interactions: + +- GPU or CPU hit testing for non-point geometry, including lines, boxes, + handles, pattern vertices, and volume profile rows. +- Drag handles with modifier constraints: horizontal, vertical, duplicate, and + proportional resize. +- Snap modes: none, OHLC, close, high/low, volume profile row, indicator value. +- Drawing lifecycle events: `on_create`, `on_update`, `on_delete`, `on_select`, + `on_hover`, and `on_commit`. +- Undo/redo command stack for all drawing edits. +- Visibility by timeframe/session, lock/hide, z-order, grouping, and templates. + +## Tool Requirements + +### Long And Short Position + +Long/short position tools are risk calculation drawings, not order execution. +They need: + +- Entry, stop, target, and right-edge/end anchors. +- Profit and loss zones rendered as translucent rectangles. +- Instrument metadata: tick size, point value, lot size, quantity precision, + currency, and leverage. +- Risk metadata: account size, fixed risk amount or account fraction. +- Computed readouts: quantity, risk/reward, target/stop distance in price, + percent and ticks, PnL, closing account balance, and open/closed state. +- Compact stats mode and axis price labels. + +### Position Forecast + +Position forecast is a two-point projection with evaluation: + +- Source and target anchors. +- Duration until the target time. +- Success/failure classification once price action reaches or expires the + projected region. +- Styling for source/target labels and result badges. + +### Bars Pattern + +Bars pattern copies historical price action into a movable drawing: + +- Source window over an OHLC mark. +- Destination anchor and optional time/price scaling. +- Display modes: OHLC sticks, candles, or line from open/high/low/close. +- Transform options: mirrored, flipped, normalized to percent move, or raw + price delta. +- It should reuse candlestick/OHLC render primitives and never duplicate a + special client renderer unless the shape actually differs. + +### Ghost Feed + +Ghost feed is a generated future-candle drawing: + +- Anchor, direction, number of bars, average high/low in ticks, variance in + ticks, and optional seed for deterministic output. +- Output is a synthetic OHLC layer with lower opacity and non-authoritative + labeling. +- It should be explicit that this is a visualization/scenario layer, not a + statistical forecast. + +### Sector + +Sector is a projected wedge: + +- Origin anchor, future horizon anchor, and target-price anchor. +- Filled polygon with border, labels, and editable handles. +- It should use a generic polygon/fill drawing primitive so it also unlocks + pattern background fills. + +### Anchored VWAP + +Anchored VWAP is a study with an anchor: + +- Source OHLCV mark and anchor bar/time. +- Price input selection: typical price, close, hlc3, ohlc4. +- Cumulative `sum(price * volume) / sum(volume)` from the anchor. +- Optional standard deviation bands. +- View-dependent recomputation should reuse sorted OHLCV windows and avoid + re-scanning the full canonical data on every pan. + +### Fixed And Anchored Volume Profile + +Volume profile is a compute-heavy finance overlay: + +- Fixed range: start/end anchors, optional extend right. +- Anchored: start anchor through the latest visible or available bar. +- Row layout: number of rows or ticks per row. +- Volume mode: total, up/down split, delta. +- Value area percentage, point of control, high-volume nodes, low-volume nodes. +- Data policy for high-resolution intrabars: accept precomputed lower-timeframe + bars from the user first; later add server/kernel downsample requests. +- Render as pane-relative horizontal bars, not ordinary x-axis bars. + +### Pattern Drawings + +Manual patterns should land before automatic detection: + +- ABCD: four editable points, AB=CD, classic ABCD, extension ratios. +- XABCD: five editable points, Gartley, Butterfly, Crab, Bat ratio validation. +- Triangle: three or more points plus optional breakout line. +- Three drives: seven points with ratio labels. +- Head and shoulders: neckline, shoulders/head points, measured move. +- Elliott waves: wave labels, nested degrees, corrective/impulse modes. +- Cycles: cyclic lines, time cycles, sine line. + +Pattern validation should return warnings and ratio badges, not block drawing. +Quant users need to see imperfect setups. + +## Production Quant Requirements + +A finance chart that is credible in a quant/trading setting needs the following +before we should market it as production-grade: + +- Time axes with sessions, holidays, range breaks, timezone-aware labels, and + stable ordinal candle spacing. +- Right-side price axes, optional log scale, percent scale, indexed scale, and + linked multi-pane crosshair. +- Multi-pane layouts with shared x-axis: price, volume, oscillator, order book, + and custom study panes. +- Instrument metadata: tick size, tick value, point value, multiplier, lot size, + currency, trading session, and corporate-action adjustment mode. +- Deterministic calculations for studies and drawings, with parity tests for + NumPy fallback and native kernels. +- Streaming updates that can append/replace the last bar without rebuilding the + entire chart or losing drawings. +- Object persistence as stable JSON, including versioning and migration. +- Export fidelity for standalone HTML: drawings and studies must work without a + live Python kernel when their required geometry has been materialized. +- Reflex integration: every drawing state change can be controlled, observed, + and customized from Reflex components without forcing React users into a + private xy UI. + +## Implementation Plan + +Current status: the Python-side API foundation has started in +`python/xy/finance.py`. It provides `finance_chart`, `finance_tools`, +instrument metadata, serializable finance layers, study/drawing factories, and +long/short position risk metrics. The client-side `LAYER_KINDS` registry has +also started in `js/src/57_layers.js` with canvas rendering for the first +finance overlays. Right-side price axes are wired through `Figure(y_side=...)` +and `fc.y_axis(side="right")`. Area marks have landed as a first-class base +price primitive with fluent and component APIs plus line-style decimation. +`volume_bars` now materializes source OHLCV data and renders as a synced lower +canvas pane beneath the price plot. +Moving averages, Bollinger bands, cumulative VWAP, and anchored VWAP have Python +reference computations and render as composed line studies when their source +data is present. Fixed/anchored volume profile specs can now carry Python-computed +profile rows for total/up-down/delta rendering, `bars_pattern` can materialize +a source OHLC window into projected canvas candles, and `ghost_feed` can create +deterministic synthetic OHLC projections from source candle cadence/range +statistics. The interactive drawing editor, hit-tested handles, snapping edits, +persistence UI, multi-pane layout, native study kernels, and production native +volume-profile kernels are still future work. Performance analytics helpers for +equity curves, returns, drawdown, returns distributions, and historical VaR/CVaR +have also landed as Python reference functions. The Tier 1 performance chart now +uses those helpers to render equity/PnL plus a synced lower drawdown pane, and +the returns-distribution chart now renders histogram bars with VaR/CVaR marker +lines. + +### Phase 1: Overlay Layer Foundation + +- Add `Layer`/`Drawing`/`Study` dataclasses and a stable JSON schema. + Python-side serializable layer objects have started; explicit `Drawing` and + `Study` subclasses can be split from the generic `Layer` when the renderer + needs type-specific behavior. +- Add component factories for `finance_chart`, `finance_tools`, and basic + drawing components. Initial factories now cover position tools, forecast + drawings, bars pattern, ghost feed, sector, volume studies, and ABCD/XABCD + pattern specs. +- Add client layer registry parallel to `MARK_KINDS`: `LAYER_KINDS[kind]`. + Initial canvas renderers now cover position boxes, projection lines, sectors, + anchored-study markers, fixed ranges, computed volume profiles, materialized + bars patterns, ghost feed, and ABCD/XABCD patterns. +- Add screen/data coordinate conversion helpers and draggable anchor handles. +- Add selection, hover, z-order, lock/hide, and delete behavior for drawings. +- Add tests for JSON round-trip, coordinate transforms, and event payloads. + +### Phase 2: Finance Axes And Panes + +- Add right-side y-axis support and pane layout. Right-side y-axis support has + landed for single-pane charts; pane layout remains. +- Add volume bars in a separate pane linked to the candlestick source. +- Add range breaks/session-aware time axes and stable candle ordinal spacing. +- Add linked crosshair across panes with OHLC readout and Reflex-customizable + tooltip payloads. + +### Phase 3: Risk And Measurement Tools + +- Implement price range, date range, and date+price range. +- Implement long and short position with full quantity/risk/PnL formulas. +- Implement position forecast and sector. +- Add compact stats mode and price-axis labels. + +### Phase 4: Forecasting Drawings + +- Implement bars pattern by copying source OHLC windows into a movable synthetic + OHLC layer. Initial materialization/rendering has landed for static projected + candles; interactive movement and edit handles remain. +- Implement ghost feed as deterministic synthetic candles with styling that + clearly separates it from real market data. Initial data-space materialization + and canvas rendering have landed; drag/edit controls remain. +- Add templates for common forecast/risk presets. + +### Phase 5: Volume Studies + +- Implement anchored VWAP and optional bands. Python-side AVWAP computation and + composed line/band traces have started; native acceleration and streaming + updates remain. +- Implement fixed range and anchored volume profile with total/up-down/delta + modes, value area, and point-of-control labels. Python-side profile rows and + canvas rendering have started; native acceleration and richer labels remain. +- Add native and NumPy parity tests for AVWAP and volume-profile kernels. + +### Phase 6: Pattern Drawings + +- Implement ABCD and XABCD manual drawings with ratio labels and validation. +- Add triangle, three drives, head and shoulders, Elliott waves, and cycle tools. +- Add snap-to-OHLC for pattern points and visibility-by-timeframe. + +### Phase 7: Auto Detection And Quant Extensions + +- Add optional pattern detectors as studies that emit candidate pattern layers. +- Add market profile, depth chart, order book heatmap, Renko, Heikin-Ashi, Kagi, + point-and-figure, and indicator library breadth. +- Add benchmarks for pan/zoom latency with hundreds of drawings and millions of + OHLCV rows. + +## Definition Of Done + +This roadmap is complete when: + +- A user can build a candlestick chart with volume, studies, long/short risk + boxes, forecasts, volume profiles, and chart patterns entirely through the + component API. +- The same state can be edited interactively, persisted to JSON, restored, and + controlled from Reflex. +- Pan/zoom remains interactive on multi-million-row OHLCV datasets because + studies and overlays are either screen-bounded, incrementally computed, or + precomputed. +- Native and NumPy fallback calculations match for all finance kernels. +- The example Reflex app contains a finance-workstation page exercising the + major tools side by side. diff --git a/examples/echarts.ipynb b/examples/echarts.ipynb new file mode 100644 index 00000000..1e13cc83 --- /dev/null +++ b/examples/echarts.ipynb @@ -0,0 +1,116 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "echarts-title", + "metadata": {}, + "source": [ + "# ECharts Notebook Smoke Test\n", + "\n", + "This notebook verifies that Apache ECharts can render from a Jupyter notebook using `pyecharts`. It intentionally stays independent of `fastcharts` so we can use it as a clean comparison/control chart when testing notebook behavior." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "echarts-imports", + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " from IPython.display import display\n", + " from pyecharts import options as opts\n", + " from pyecharts.charts import Bar, Line\n", + " from pyecharts.globals import CurrentConfig\n", + "except ModuleNotFoundError as exc:\n", + " raise RuntimeError(\n", + " \"Install notebook example dependencies with: \"\n", + " \"uv pip install nbformat nbclient nbconvert pyecharts\"\n", + " ) from exc\n", + "\n", + "# The default pyecharts asset host can be sensitive to certificate/date\n", + "# issues in automated browsers. Use a version-pinned public ECharts build.\n", + "CurrentConfig.ONLINE_HOST = \"https://cdn.jsdelivr.net/npm/echarts@5.6.0/dist/\"\n", + "\n", + "print(\"pyecharts import ok\")" + ] + }, + { + "cell_type": "markdown", + "id": "echarts-chart-heading", + "metadata": {}, + "source": [ + "## Combined Bar + Line Chart\n", + "\n", + "The chart below exercises the ECharts runtime, tooltip config, dual y-axes, and an overlapped series." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "echarts-chart", + "metadata": {}, + "outputs": [], + "source": [ + "months = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\"]\n", + "revenue = [42, 58, 61, 73, 86, 97]\n", + "latency_ms = [38, 34, 31, 29, 25, 22]\n", + "\n", + "bar = (\n", + " Bar(init_opts=opts.InitOpts(width=\"900px\", height=\"420px\", renderer=\"canvas\"))\n", + " .add_xaxis(months)\n", + " .add_yaxis(\"Revenue\", revenue, color=\"#3b82f6\")\n", + " .extend_axis(\n", + " yaxis=opts.AxisOpts(\n", + " name=\"Latency ms\",\n", + " position=\"right\",\n", + " axislabel_opts=opts.LabelOpts(formatter=\"{value} ms\"),\n", + " )\n", + " )\n", + " .set_global_opts(\n", + " title_opts=opts.TitleOpts(title=\"ECharts notebook smoke test\"),\n", + " tooltip_opts=opts.TooltipOpts(trigger=\"axis\"),\n", + " legend_opts=opts.LegendOpts(pos_top=\"8%\"),\n", + " xaxis_opts=opts.AxisOpts(name=\"Month\"),\n", + " yaxis_opts=opts.AxisOpts(name=\"Revenue\"),\n", + " )\n", + ")\n", + "\n", + "line = Line().add_xaxis(months).add_yaxis(\"Latency ms\", latency_ms, yaxis_index=1, color=\"#ef4444\")\n", + "\n", + "chart = bar.overlap(line)\n", + "notebook_chart = chart.render_notebook()\n", + "html = notebook_chart.data\n", + "\n", + "assert \"cdn.jsdelivr.net/npm/echarts@5.6.0/dist/echarts.min\" in html\n", + "assert \"require(['echarts']\" in html\n", + "assert \"echarts.init\" in html\n", + "assert \"setOption\" in html\n", + "assert \"ECharts notebook smoke test\" in html\n", + "print(\"ECharts embed smoke test passed\")\n", + "\n", + "display(notebook_chart)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/js/src/00_header.ts b/js/src/00_header.ts index 9f1482eb..b5231539 100644 --- a/js/src/00_header.ts +++ b/js/src/00_header.ts @@ -61,6 +61,7 @@ export const PROTOCOL = 12; // so adding a channel buffer cannot silently reintroduce the leak. export const TRACE_GPU_BUFFERS = [ "xBuf", "yBuf", "cBuf", "sBuf", "selBuf", "baseBuf", + "oBuf", "hBuf", "lBuf", "x0Buf", "x1Buf", "x2Buf", "y0Buf", "y1Buf", "y2Buf", "t0Buf", "t1Buf", "posBuf", "value1Buf", "value0Buf", diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index dc33bac3..713fe60f 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -31,6 +31,9 @@ export type ShaderResolver = (type: number, source: string) => WebGLShader; // grid quad: a_corner). WebGL2 guarantees >= 16 attribs; the max used is 15. export const ATTR_SLOTS = { ax: 0, ay: 1, + // Finance marks use a separate program, so their six scalar channels can + // safely reuse the base geometry slots. + a_x: 0, a_open: 1, a_high: 2, a_low: 3, a_close: 4, a_dir: 5, ax0: 0, ax1: 1, ay0: 2, ay1: 3, ax2: 4, ay2: 5, ab0: 4, ab1: 5, a_pos: 0, a_v1: 1, a_v0: 2, a_corner: 0, @@ -1230,6 +1233,59 @@ void main() { outColor = premult; }`; +// Candlestick: one instanced quad per candle, drawn as wick+body for candles +// and as stem+ticks for OHLC bars. A minimum 1px extent keeps doji visible. +export const CANDLE_VS = `#version 300 es +in float a_x; in float a_open; in float a_high; in float a_low; in float a_close; in float a_dir; +uniform vec2 u_xmap; uniform vec2 u_ymap; uniform vec2 u_res; +uniform float u_halfPx; uniform int u_part; +out float v_dir; out vec2 v_local; flat out float v_hpx; +const vec2 corners[4] = vec2[4](vec2(0.,0.), vec2(1.,0.), vec2(0.,1.), vec2(1.,1.)); +void main() { + float yLo, yHi; + if (u_part == 1) { yLo = min(a_open, a_close); yHi = max(a_open, a_close); } + else if (u_part == 2) { yLo = a_open; yHi = a_open; } + else if (u_part == 3) { yLo = a_close; yHi = a_close; } + else { yLo = a_low; yHi = a_high; } + float xcPx = ((a_x * u_xmap.x + u_xmap.y) * 0.5 + 0.5) * u_res.x; + float ylPx = ((yLo * u_ymap.x + u_ymap.y) * 0.5 + 0.5) * u_res.y; + float yhPx = ((yHi * u_ymap.x + u_ymap.y) * 0.5 + 0.5) * u_res.y; + if (abs(yhPx - ylPx) < 1.0) { float mid = (ylPx + yhPx) * 0.5; ylPx = mid - 0.5; yhPx = mid + 0.5; } + vec2 c = corners[gl_VertexID]; + float xL = u_part == 3 ? xcPx : xcPx - u_halfPx; + float xR = u_part == 2 ? xcPx : xcPx + u_halfPx; + float xPx = mix(xL, xR, c.x); + float yPx = mix(ylPx, yhPx, c.y); + gl_Position = vec4(vec2(xPx / u_res.x, yPx / u_res.y) * 2.0 - 1.0, 0.0, 1.0); + v_dir = a_dir; + v_local = c; + v_hpx = abs(yhPx - ylPx); +}`; + +export const CANDLE_FS = `#version 300 es +precision highp float; precision highp int; +uniform vec4 u_up; uniform vec4 u_down; uniform vec4 u_wick; uniform float u_opacity; +uniform float u_halfPx; uniform int u_isWick; uniform int u_wickFixed; uniform int u_hollowUp; +in float v_dir; in vec2 v_local; flat in float v_hpx; +out vec4 outColor; +void main() { + bool up = v_dir > 0.5; + vec3 rgb; + if (u_isWick == 1) { + rgb = u_wickFixed == 1 ? u_wick.rgb : (up ? u_up.rgb : u_down.rgb); + } else { + rgb = up ? u_up.rgb : u_down.rgb; + if (u_hollowUp == 1 && up) { + float ex = min(v_local.x, 1.0 - v_local.x) * (u_halfPx * 2.0); + float ey = min(v_local.y, 1.0 - v_local.y) * v_hpx; + if (ex > 1.0 && ey > 1.0) discard; + } + } + float a = u_opacity; + if (a <= 0.001) discard; + outColor = vec4(rgb * a, a); +}`; + // Rectangles: one instanced quad per mark. Geometry columns are left/right and // bottom/top in data space, each offset-encoded independently (§4). This is the // primitive for histogram, bar/column, waterfall, and later heatmap cells. diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 7e8f2297..e1fb58f5 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -2,10 +2,11 @@ import { PROTOCOL, TRACE_GPU_BUFFERS, xyByteSpan } from "./00_header"; import { buildLutData, colormapKey, colormapStops } from "./10_colormaps"; import { chartBackdrop, cssColor, ensureChromeStylesheet, hexColor, parseColor, readTheme, safeCssPaint } from "./20_theme"; import { angularTicks, categoryTicks, fmtAxis, fmtGeneral, fmtLinear, fmtLog, fmtValue, linearTicks, logTicks, timeTicks } from "./30_ticks"; -import { AREA_FS, AREA_VS, ATTR_SLOTS, BAR_VS, DENSITY_FS, GRID_VS, HEATMAP_FS, LINE_CAP_MODES, LINE_FS, LINE_VS, MESH_FS, MESH_VS, PICK_FS, PICK_VS, POINT_FS, POINT_SIMPLE_FS, POINT_SIMPLE_VS, POINT_VS, RECT_FS, RECT_VS, RIBBON_FS, RIBBON_STEPS, RIBBON_VS, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl"; +import { AREA_FS, AREA_VS, ATTR_SLOTS, BAR_VS, CANDLE_FS, CANDLE_VS, DENSITY_FS, GRID_VS, HEATMAP_FS, LINE_CAP_MODES, LINE_FS, LINE_VS, MESH_FS, MESH_VS, PICK_FS, PICK_VS, POINT_FS, POINT_SIMPLE_FS, POINT_SIMPLE_VS, POINT_VS, RECT_FS, RECT_VS, RIBBON_FS, RIBBON_STEPS, RIBBON_VS, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl"; import { acquireGLHost } from "./42_glhost"; import { lodCopyGrid, lodDecodeLogU8, lodDrawDensityTier, lodDropDensityCache, lodDropPointCache, lodRememberDensity, lodSampleForView, lodWriteGridTexture } from "./45_lod"; import { markOf } from "./55_marks"; +import { layerOf } from "./57_layers"; // --------------------------------------------------------------------------- // ChartView @@ -490,6 +491,7 @@ export class ChartView { this.interaction = spec.interaction || {}; this.markStyle = spec.mark_style || {}; this.axes = this._normalizeAxes(spec); + this.layers = Array.isArray(spec.layers) ? spec.layers : []; this.comm = comm; this.seq = 0; this._densityStamp = 0; @@ -772,6 +774,61 @@ export class ChartView { // that object and an alias would freeze the pre-recut geometry. this._legendRect = null; this._recutPolarPlot(compact); + this._layoutFinancePanes(); + } + + _hasVolumePane() { + return this.layers.some((layer) => { + const bars = layer?.props?.bars; + return layer?.kind === "volume_bars" && + layer.props?.pane !== "overlay" && + bars && Array.isArray(bars.volume) && bars.volume.length > 0; + }); + } + + _oscillatorLayers() { + return this.layers.filter((layer) => { + const series = layer?.props?.series; + return ["rsi", "macd", "stochastic", "equity_drawdown"].includes(layer?.kind) && + layer.props?.pane !== "overlay" && + series && Array.isArray(series.x) && series.x.length > 0; + }); + } + + _oscillatorPaneFor(layer) { + return (this.oscillatorPanes || []).find((pane) => pane.layer === layer) || null; + } + + _layoutFinancePanes() { + this.volumePane = null; + this.oscillatorPanes = []; + if (this.spec?.coords === "polar" || !this.layers.length) return; + const hasVolume = this._hasVolumePane(); + const oscillators = this._oscillatorLayers(); + const paneCount = (hasVolume ? 1 : 0) + oscillators.length; + if (!paneCount) return; + + const availableH = this.plot.h; + const gap = 10; + let paneH = Math.max( + 44, + Math.min(86, Math.floor((availableH * 0.42) / paneCount)), + ); + if (availableH - (paneH + gap) * paneCount < 90) { + paneH = Math.max(36, Math.floor((availableH - 90 - gap * paneCount) / paneCount)); + } + this.plot.h = Math.max(40, availableH - (paneH + gap) * paneCount); + let paneY = this.plot.y + this.plot.h; + if (hasVolume) { + paneY += gap; + this.volumePane = { x: this.plot.x, y: paneY, w: this.plot.w, h: paneH }; + paneY += paneH; + } + for (const layer of oscillators) { + paneY += gap; + this.oscillatorPanes.push({ layer, x: this.plot.x, y: paneY, w: this.plot.w, h: paneH }); + paneY += paneH; + } } // Side and px a polar legend gutter claims, or null when nothing is reserved: @@ -3824,6 +3881,7 @@ export class ChartView { get areaProg() { return this._prog("area", AREA_VS, AREA_FS); } get rectProg() { return this._prog("rect", RECT_VS, RECT_FS); } get barProg() { return this._prog("bar", BAR_VS, RECT_FS); } + get candleProg() { return this._prog("candle", CANDLE_VS, CANDLE_FS); } get pickProg() { return this._prog("pick", PICK_VS, PICK_FS); } get densityProg() { return this._prog("density", GRID_VS, DENSITY_FS); } get heatmapProg() { return this._prog("heatmap", GRID_VS, HEATMAP_FS); } @@ -4873,6 +4931,94 @@ export class ChartView { if (!truecolor) g._cpuHeatmap = { grid }; } + _buildCandleMark(g, t, buffer) { + const column = (ref) => this._columnView(buffer, this.spec.columns[ref]); + const style = t.style || {}; + g.candle = { + up: parseColor(this.root, style.up_color, [0.15, 0.65, 0.6, 1]), + down: parseColor(this.root, style.down_color, [0.94, 0.33, 0.31, 1]), + widthFrac: style.width_frac ?? 0.7, + opacity: style.opacity ?? 1, + hollow: !!style.hollow, + wick: style.wick_color + ? parseColor(this.root, style.wick_color, [0.15, 0.65, 0.6, 1]) + : null, + }; + this._fillCandle(g, { + x: column(t.x), + o: column(t.open), + h: column(t.high), + l: column(t.low), + c: column(t.close), + xMeta: { ...this.spec.columns[t.x] }, + yMeta: { ...this.spec.columns[t.close] }, + }); + } + + _applyCandleUpdate(g, upd, buffers) { + if (!g.candle) return; + this._fillCandle(g, { + x: this._asF32(buffers[upd.x.buf]), + o: this._asF32(buffers[upd.open.buf]), + h: this._asF32(buffers[upd.high.buf]), + l: this._asF32(buffers[upd.low.buf]), + c: this._asF32(buffers[upd.close.buf]), + xMeta: { ...g.xMeta, offset: upd.x.offset, scale: upd.x.scale }, + yMeta: { ...g.yMeta, offset: upd.close.offset, scale: upd.close.scale }, + }); + } + + _fillCandle(g, encoded) { + const candle = g.candle; + this._deleteVaos(g); + this._deleteBuffers(candle, ["xBuf", "oBuf", "hBuf", "lBuf", "cBuf", "dBuf"]); + g.xMeta = encoded.xMeta; + g.yMeta = encoded.yMeta; + g.n = Math.min( + encoded.x.length, + encoded.o.length, + encoded.h.length, + encoded.l.length, + encoded.c.length, + ); + const direction = new Float32Array(g.n); + for (let i = 0; i < g.n; i++) direction[i] = encoded.c[i] >= encoded.o[i] ? 1 : 0; + candle.xBuf = this._upload(encoded.x); + candle.oBuf = this._upload(encoded.o); + candle.hBuf = this._upload(encoded.h); + candle.lBuf = this._upload(encoded.l); + candle.cBuf = this._upload(encoded.c); + candle.dBuf = this._upload(direction); + + const xOffset = g.xMeta.offset || 0; + const yOffset = g.yMeta.offset || 0; + const xScale = g.xMeta.scale || 1; + const yScale = g.yMeta.scale || 1; + const x = new Float64Array(g.n); + const open = new Float64Array(g.n); + const high = new Float64Array(g.n); + const low = new Float64Array(g.n); + const close = new Float64Array(g.n); + for (let i = 0; i < g.n; i++) { + x[i] = encoded.x[i] / xScale + xOffset; + open[i] = encoded.o[i] / yScale + yOffset; + high[i] = encoded.h[i] / yScale + yOffset; + low[i] = encoded.l[i] / yScale + yOffset; + close[i] = encoded.c[i] / yScale + yOffset; + } + let dxMed = 1; + if (g.n > 1) { + const diffs = []; + for (let i = 1; i < g.n; i++) { + const difference = Math.abs(x[i] - x[i - 1]); + if (Number.isFinite(difference) && difference > 0) diffs.push(difference); + } + diffs.sort((a, b) => a - b); + if (diffs.length) dxMed = diffs[diffs.length >> 1]; + } + candle.cpu = { x, o: open, h: high, l: low, c: close, dxMed }; + } + _uploadRgbaGrid(channels, w, h) { const gl = this.gl; const tex = gl.createTexture(); @@ -6303,6 +6449,98 @@ export class ChartView { gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, count); } + _bindCandleVao(g) { + const candle = g.candle; + this._bindVao( + g, + "candle", + [ + candle.xBuf._fcId, + candle.oBuf._fcId, + candle.hBuf._fcId, + candle.lBuf._fcId, + candle.cBuf._fcId, + candle.dBuf._fcId, + ], + () => { + this._vaoAttr(ATTR_SLOTS.a_x, candle.xBuf, 0, 1); + this._vaoAttr(ATTR_SLOTS.a_open, candle.oBuf, 0, 1); + this._vaoAttr(ATTR_SLOTS.a_high, candle.hBuf, 0, 1); + this._vaoAttr(ATTR_SLOTS.a_low, candle.lBuf, 0, 1); + this._vaoAttr(ATTR_SLOTS.a_close, candle.cBuf, 0, 1); + this._vaoAttr(ATTR_SLOTS.a_dir, candle.dBuf, 0, 1); + }, + ); + } + + _setCandleUniforms(g, x0, x1, y0, y1) { + const gl = this.gl; + const program = this.candleProg; + const uniform = (name) => uniformOf(gl, program, name); + const xMap = this._map(g.xMeta, x0, x1); + const yMap = this._map(g.yMeta, y0, y1); + const candle = g.candle; + gl.uniform2f(uniform("u_xmap"), xMap[0], xMap[1]); + gl.uniform2f(uniform("u_ymap"), yMap[0], yMap[1]); + gl.uniform2f(uniform("u_res"), this.canvas.width, this.canvas.height); + gl.uniform4f(uniform("u_up"), candle.up[0], candle.up[1], candle.up[2], 1); + gl.uniform4f(uniform("u_down"), candle.down[0], candle.down[1], candle.down[2], 1); + const wick = candle.wick || candle.up; + gl.uniform4f(uniform("u_wick"), wick[0], wick[1], wick[2], 1); + gl.uniform1f( + uniform("u_opacity"), + candle.opacity * (g._transitionOpacity ?? 1) * (g._legendDim ?? 1), + ); + this._bindCandleVao(g); + return uniform; + } + + _drawCandles(g, x0, x1, y0, y1) { + if (!g.n) return; + const gl = this.gl; + gl.useProgram(this.candleProg); + const uniform = this._setCandleUniforms(g, x0, x1, y0, y1); + const candle = g.candle; + const slotPx = (candle.cpu.dxMed / Math.max(Math.abs(x1 - x0), 1e-30)) * this.canvas.width; + const bodyHalf = Math.max(0.5 * this.dpr, slotPx * candle.widthFrac * 0.5); + const wickHalf = Math.min(bodyHalf, Math.max(0.5 * this.dpr, 0.6 * this.dpr)); + + gl.uniform1i(uniform("u_part"), 0); + gl.uniform1i(uniform("u_isWick"), 1); + gl.uniform1i(uniform("u_wickFixed"), candle.wick ? 1 : 0); + gl.uniform1i(uniform("u_hollowUp"), 0); + gl.uniform1f(uniform("u_halfPx"), wickHalf); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); + + gl.uniform1i(uniform("u_part"), 1); + gl.uniform1i(uniform("u_isWick"), 0); + gl.uniform1i(uniform("u_hollowUp"), candle.hollow ? 1 : 0); + gl.uniform1f(uniform("u_halfPx"), bodyHalf); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); + } + + _drawOHLC(g, x0, x1, y0, y1) { + if (!g.n) return; + const gl = this.gl; + gl.useProgram(this.candleProg); + const uniform = this._setCandleUniforms(g, x0, x1, y0, y1); + const candle = g.candle; + const slotPx = (candle.cpu.dxMed / Math.max(Math.abs(x1 - x0), 1e-30)) * this.canvas.width; + const tickPx = Math.max(this.dpr, slotPx * candle.widthFrac * 0.5); + const stemHalf = Math.max(0.5 * this.dpr, 0.6 * this.dpr); + gl.uniform1i(uniform("u_isWick"), 0); + gl.uniform1i(uniform("u_wickFixed"), 0); + gl.uniform1i(uniform("u_hollowUp"), 0); + const drawPart = (part, halfPx) => { + gl.uniform1i(uniform("u_part"), part); + gl.uniform1f(uniform("u_halfPx"), halfPx); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); + }; + drawPart(0, stemHalf); + drawPart(2, tickPx); + drawPart(3, tickPx); + } + _drawRects(g, x0, x1, y0, y1, edgePad = [0, 0, 0, 0]) { if (!g.n) return; const gl = this.gl; @@ -6500,6 +6738,69 @@ export class ChartView { return this._dataPx("y", value); } + _dataToScreenX(value) { + return this._dataPx("x", value); + } + + _dataToScreenY(value) { + return this._dataPx("y", value); + } + + _anchorPoint(anchor) { + if (!anchor) return null; + const hasX = anchor.x !== undefined && anchor.x !== null; + const hasY = anchor.y !== undefined && anchor.y !== null; + const number = (value) => { + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : NaN; + }; + return { + x: hasX ? number(anchor.x) : null, + y: hasY ? number(anchor.y) : null, + hasX, + hasY, + }; + } + + _layerColor(layer, key, fallback) { + return parseColor(this.root, layer.style && layer.style[key], fallback); + } + + _drawLayers(ctx) { + if (!this.layers.length) return; + const volumeLayers = this.layers.filter((layer) => layer.kind === "volume_bars"); + if (this.volumePane && volumeLayers.length) { + const pane = this.volumePane; + ctx.save(); + ctx.beginPath(); + ctx.rect(pane.x, pane.y, pane.w, pane.h); + ctx.clip(); + for (const layer of volumeLayers) layerOf(layer.kind).draw(this, ctx, layer); + ctx.restore(); + } + for (const layer of this.layers) { + const pane = this._oscillatorPaneFor(layer); + if (!pane) continue; + ctx.save(); + ctx.beginPath(); + ctx.rect(pane.x, pane.y, pane.w, pane.h); + ctx.clip(); + layerOf(layer.kind).draw(this, ctx, layer); + ctx.restore(); + } + ctx.save(); + ctx.beginPath(); + ctx.rect(this.plot.x, this.plot.y, this.plot.w, this.plot.h); + ctx.clip(); + for (const layer of this.layers) { + if (layer.kind === "volume_bars" || this._oscillatorPaneFor(layer)) continue; + layerOf(layer.kind).draw(this, ctx, layer); + } + ctx.restore(); + } + // A point-anchored (theta, r) pair in canvas px. The separable _dataPxX / // _dataPxY pair cannot express polar placement: it reads (0, 0) — the disc // centre, at any angle — as the bottom-left corner, and strings a set of @@ -7543,6 +7844,7 @@ export class ChartView { // Label layout resolves responsive callout offsets before the pointer is // painted, keeping its start attached when an edge clamp moves the text. this._drawAuthoredScatterMarkers(octx); + this._drawLayers(octx); this._drawAnnotationShapes(octx); } @@ -7821,6 +8123,23 @@ export class ChartView { return best; } + _candleHover(g, dataX) { + const cpu = g.candle?.cpu; + if (!cpu || !g.n) return null; + const x = cpu.x; + let lo = 0; + let hi = g.n - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (x[mid] < dataX) lo = mid + 1; + else hi = mid; + } + if (lo > 0 && Math.abs(x[lo - 1] - dataX) <= Math.abs(x[lo] - dataX)) lo -= 1; + const distance = Math.abs(x[lo] - dataX); + if (distance > cpu.dxMed * 0.6) return null; + return { trace: g.trace.id, index: lo, g, dist: distance, synthetic: true }; + } + _hoverAt(cssX, cssY) { const maxPx = 12; let best = null; @@ -7829,6 +8148,11 @@ export class ChartView { if (g.tier === "density") continue; const [dataX, dataY] = this._dataFromCanvas(cssX, cssY, g.xAxis, g.yAxis); if (!Number.isFinite(dataX) || !Number.isFinite(dataY)) continue; + if (g.candle?.cpu) { + const hit = this._candleHover(g, dataX); + if (hit) return hit; + continue; + } if (g.heatmap && g._cpuHeatmap) { const hit = this._heatmapHover(g, dataX, dataY); if (hit) return hit; @@ -8232,6 +8556,7 @@ export class ChartView { // can own; the build paths and this teardown must not drift apart, so both // sides read the same names (see the constant for how it is enforced). this._deleteBuffers(g, TRACE_GPU_BUFFERS); + this._deleteBuffers(g.candle, ["xBuf", "oBuf", "hBuf", "lBuf", "cBuf", "dBuf"]); // Only geometry is owned independently by the retained M4 overview; // style/channel buffers are shared with the live trace and were deleted // above exactly once. @@ -8261,6 +8586,7 @@ export class ChartView { g._legendHoverPrevTex = null; g.densityCache = []; g.heatmap = null; + g.candle = null; g._cpu = null; g._homeDecimated = null; } diff --git a/js/src/52_tooltip.ts b/js/src/52_tooltip.ts index b50174bd..2ed5b0d0 100644 --- a/js/src/52_tooltip.ts +++ b/js/src/52_tooltip.ts @@ -78,6 +78,16 @@ Object.assign(ChartView.prototype, { row.y = y; if (xKind !== undefined) row.x_kind = xKind; if (yKind !== undefined) row.y_kind = yKind; + } else if (g.candle?.cpu) { + const candle = g.candle.cpu; + const rawX = candle.x[hit.index]; + const [x, xKind] = this._sourceDisplayValue(g, "x", rawX, g.xMeta?.kind); + row.x = x; + row.open = candle.o[hit.index]; + row.high = candle.h[hit.index]; + row.low = candle.l[hit.index]; + row.close = candle.c[hit.index]; + if (xKind !== undefined) row.x_kind = xKind; } else if (cpu) { const xMeta = cpu.xMeta || g.xMeta; const yMeta = cpu.yMeta || g.yMeta; @@ -345,6 +355,16 @@ Object.assign(ChartView.prototype, { }); } } + for (const [field, fallback] of [ + ["open", "Open"], + ["high", "High"], + ["low", "Low"], + ["close", "Close"], + ]) { + if (row[field] === undefined) continue; + const { label } = this._defaultTooltipLabel(field, fallback, labels, aliases); + items.push({ kind: "field", label, value: fmtValue(row[field], row.y_kind) }); + } if (row.y !== undefined) { const polar = this._polarTooltipField("y", row.y, row.y_kind); const { label, customized } = this._defaultTooltipLabel("y", "y", labels, aliases); diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index 638bc502..1510cf7b 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -731,6 +731,11 @@ Object.assign(ChartView.prototype, { for (const upd of msg.traces) { const g = this.gpuTraces.find((t) => t.trace.id === upd.id); if (!g) continue; + // OHLC tier updates re-upload all four price columns as one unit. + if (upd.open && upd.high && upd.low && upd.close && g.candle) { + this._applyCandleUpdate(g, upd, buffers); + continue; + } const gl = this.gl; const xArr = this._asF32(buffers[upd.x.buf]); const yArr = this._asF32(buffers[upd.y.buf]); diff --git a/js/src/55_marks.ts b/js/src/55_marks.ts index b3c3e154..f7795da2 100644 --- a/js/src/55_marks.ts +++ b/js/src/55_marks.ts @@ -242,6 +242,36 @@ export const MARK_KINDS = { }, }, area: AREA_MARK, + candlestick: { + build: (view, g, t, buffer) => view._buildCandleMark(g, t, buffer), + draw: (view, g) => { + const [x0, x1] = view._axisRange(g.xAxis); + const [y0, y1] = view._axisRange(g.yAxis); + view._drawCandles(g, x0, x1, y0, y1); + }, + refreshColor: (view, g) => { + g.candle.up = parseColor(view.root, g.trace.style.up_color, g.candle.up); + g.candle.down = parseColor(view.root, g.trace.style.down_color, g.candle.down); + g.candle.wick = g.trace.style.wick_color + ? parseColor(view.root, g.trace.style.wick_color, g.candle.wick) + : null; + }, + }, + ohlc: { + build: (view, g, t, buffer) => view._buildCandleMark(g, t, buffer), + draw: (view, g) => { + const [x0, x1] = view._axisRange(g.xAxis); + const [y0, y1] = view._axisRange(g.yAxis); + view._drawOHLC(g, x0, x1, y0, y1); + }, + refreshColor: (view, g) => { + g.candle.up = parseColor(view.root, g.trace.style.up_color, g.candle.up); + g.candle.down = parseColor(view.root, g.trace.style.down_color, g.candle.down); + g.candle.wick = g.trace.style.wick_color + ? parseColor(view.root, g.trace.style.wick_color, g.candle.wick) + : null; + }, + }, }; // Registry lookup with the scatter fallback every dispatch site shares. diff --git a/js/src/57_layers.ts b/js/src/57_layers.ts new file mode 100644 index 00000000..0d42bde1 --- /dev/null +++ b/js/src/57_layers.ts @@ -0,0 +1,891 @@ +// --------------------------------------------------------------------------- +// Finance layer registry — canvas overlays above WebGL marks. +// +// This mirrors MARK_KINDS for non-data layers. Marks own binary columns and +// WebGL draw calls; layers own small JSON anchors/props plus canvas geometry. +// --------------------------------------------------------------------------- + +import { fmtLinear } from "./30_ticks"; + +function rgba(c, alpha = 1) { + return `rgba(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)},${alpha})`; +} + +function layerAnchor(view, layer, name) { + return view._anchorPoint(layer.anchors && layer.anchors[name]); +} + +function finitePoint(p) { + return p && Number.isFinite(p.x) && Number.isFinite(p.y); +} + +function xFrom(view, p, fallback) { + return p && p.hasX && Number.isFinite(p.x) ? view._dataToScreenX(p.x) : fallback; +} + +function yFrom(view, p, fallback) { + return p && p.hasY && Number.isFinite(p.y) ? view._dataToScreenY(p.y) : fallback; +} + +function drawLabel(ctx, text, x, y, color, bg = null) { + if (!text) return; + ctx.save(); + ctx.font = "11px system-ui,sans-serif"; + const w = ctx.measureText(text).width + 10; + const h = 18; + ctx.fillStyle = bg || "rgba(15,19,28,.82)"; + ctx.strokeStyle = "rgba(255,255,255,.15)"; + ctx.lineWidth = 1; + ctx.beginPath(); + roundedRect(ctx, x, y - h / 2, w, h, 4); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = color || "#fff"; + ctx.fillText(text, x + 5, y + 4); + ctx.restore(); +} + +function roundedRect(ctx, x, y, w, h, r) { + const rr = Math.min(r, Math.abs(w) / 2, Math.abs(h) / 2); + ctx.moveTo(x + rr, y); + ctx.lineTo(x + w - rr, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + rr); + ctx.lineTo(x + w, y + h - rr); + ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h); + ctx.lineTo(x + rr, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - rr); + ctx.lineTo(x, y + rr); + ctx.quadraticCurveTo(x, y, x + rr, y); +} + +function drawLine(ctx, x1, y1, x2, y2, color, width = 1.5, dash = []) { + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.setLineDash(dash); + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + ctx.restore(); +} + +function drawHandle(ctx, x, y, color) { + ctx.save(); + ctx.fillStyle = color; + ctx.strokeStyle = "rgba(255,255,255,.9)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(x, y, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.restore(); +} + +function drawPosition(view, ctx, layer) { + const a = layer.anchors || {}; + const entry = view._anchorPoint(a.entry); + const stop = view._anchorPoint(a.stop); + const target = view._anchorPoint(a.target); + const end = view._anchorPoint(a.end); + if (!entry || !Number.isFinite(entry.y) || !stop || !target) return; + const p = view.plot; + const x1 = xFrom(view, entry, p.x); + const x2 = xFrom(view, end, p.x + p.w); + const yEntry = view._dataToScreenY(entry.y); + const yStop = yFrom(view, stop, yEntry); + const yTarget = yFrom(view, target, yEntry); + const targetColor = view._layerColor(layer, "target_color", [0.06, 0.67, 0.47, 1]); + const stopColor = view._layerColor(layer, "stop_color", [0.9, 0.24, 0.29, 1]); + const lineColor = view._layerColor(layer, "line_color", [0.78, 0.82, 0.9, 1]); + const targetCss = rgba(targetColor, 0.20); + const stopCss = rgba(stopColor, 0.20); + ctx.save(); + ctx.fillStyle = targetCss; + ctx.fillRect(x1, Math.min(yEntry, yTarget), x2 - x1, Math.abs(yTarget - yEntry)); + ctx.fillStyle = stopCss; + ctx.fillRect(x1, Math.min(yEntry, yStop), x2 - x1, Math.abs(yStop - yEntry)); + ctx.strokeStyle = rgba(lineColor, 0.95); + ctx.lineWidth = 1.25; + for (const y of [yTarget, yEntry, yStop]) { + ctx.beginPath(); + ctx.moveTo(x1, y); + ctx.lineTo(x2, y); + ctx.stroke(); + } + ctx.restore(); + const m = layer.metrics || {}; + const side = layer.side === "short" ? "SHORT" : layer.side === "long" ? "LONG" : ""; + drawLabel(ctx, `${side ? side + " " : ""}R:R ${Number(m.risk_reward || 0).toFixed(2)}`, x2 + 6, yEntry, "#fff"); + drawLabel(ctx, `TP ${fmtLinear(target.y, 1)}`, x1 + 6, yTarget, "#fff", rgba(targetColor, 0.9)); + drawLabel(ctx, `SL ${fmtLinear(stop.y, 1)}`, x1 + 6, yStop, "#fff", rgba(stopColor, 0.9)); + drawHandle(ctx, x1, yEntry, rgba(lineColor, 1)); +} + +function drawProjection(view, ctx, layer) { + const start = layerAnchor(view, layer, "start") || layerAnchor(view, layer, "origin"); + const target = layerAnchor(view, layer, "target"); + if (!finitePoint(start) || !finitePoint(target)) return; + const x1 = view._dataToScreenX(start.x); + const y1 = view._dataToScreenY(start.y); + const x2 = view._dataToScreenX(target.x); + const y2 = view._dataToScreenY(target.y); + const color = rgba(view._layerColor(layer, "color", [0.23, 0.51, 0.96, 1]), 1); + drawLine(ctx, x1, y1, x2, y2, color, 2, [5, 4]); + drawHandle(ctx, x1, y1, color); + drawHandle(ctx, x2, y2, color); + drawLabel(ctx, layer.kind === "sector" ? "Sector" : "Forecast", x2 + 6, y2, "#fff"); +} + +function drawSector(view, ctx, layer) { + const origin = layerAnchor(view, layer, "origin"); + const horizon = layerAnchor(view, layer, "horizon"); + const target = layerAnchor(view, layer, "target"); + if (!origin || !target || !Number.isFinite(origin.x) || !Number.isFinite(origin.y)) return; + const p = view.plot; + const x0 = view._dataToScreenX(origin.x); + const y0 = view._dataToScreenY(origin.y); + const x1 = xFrom(view, horizon, xFrom(view, target, p.x + p.w)); + const y1 = view._dataToScreenY(target.y); + const color = view._layerColor(layer, "color", [0.23, 0.51, 0.96, 1]); + ctx.save(); + ctx.fillStyle = rgba(color, 0.14); + ctx.strokeStyle = rgba(color, 0.9); + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + ctx.lineTo(x1, y0 + (y0 - y1)); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.restore(); + drawHandle(ctx, x0, y0, rgba(color, 1)); + drawLabel(ctx, "Sector", x1 + 6, y1, "#fff"); +} + +function drawVolumeProfile(view, ctx, layer, opts: any = {}) { + const props = layer.props || {}; + const profile = props.profile; + if (!profile || !Array.isArray(profile.total) || !profile.total.length) return false; + const p = view.plot; + const total = profile.total; + const up = Array.isArray(profile.up) ? profile.up : total; + const down = Array.isArray(profile.down) ? profile.down : []; + const low = profile.price_low || []; + const high = profile.price_high || []; + const valueArea = profile.value_area || []; + const maxTotal = Number(profile.max_total || Math.max(...total, 0)); + if (!maxTotal) return false; + + const color = view._layerColor(layer, "color", [0.56, 0.64, 0.76, 1]); + const upColor = view._layerColor(layer, "up_color", [0.13, 0.67, 0.58, 1]); + const downColor = view._layerColor(layer, "down_color", [0.95, 0.21, 0.27, 1]); + const pocColor = view._layerColor(layer, "poc_color", [0.96, 0.68, 0.19, 1]); + const mode = props.volume || "total"; + const rangeW = Math.abs((opts.x1 || p.x + p.w) - (opts.x0 || p.x)); + const maxW = Math.min( + 230, + Math.max(60, (opts.anchored ? p.w : rangeW || p.w) * 0.34), + Math.max(40, p.w - 20) + ); + let right = opts.anchored ? p.x + p.w - 7 : Math.max(opts.x0 || p.x, opts.x1 || p.x + p.w) - 4; + right = Math.max(p.x + maxW + 8, Math.min(p.x + p.w - 6, right)); + const left = Math.max(p.x + 8, right - maxW); + const availableW = right - left; + + ctx.save(); + for (let i = 0; i < total.length; i++) { + const t = Number(total[i] || 0); + if (t <= 0 || !Number.isFinite(Number(low[i])) || !Number.isFinite(Number(high[i]))) continue; + const y0 = view._dataToScreenY(Number(high[i])); + const y1 = view._dataToScreenY(Number(low[i])); + const top = Math.max(p.y, Math.min(y0, y1)); + const bottom = Math.min(p.y + p.h, Math.max(y0, y1)); + const h = Math.max(1, bottom - top - 0.5); + const w = Math.max(1, (t / maxTotal) * availableW); + const x = right - w; + const isVa = Boolean(valueArea[i]); + const isPoc = i === Number(profile.poc_index); + + if (mode === "up_down") { + const upShare = t ? Math.max(0, Number(up[i] || 0)) / t : 0; + const upW = Math.max(0, Math.min(w, w * upShare)); + const downW = w - upW; + ctx.fillStyle = rgba(downColor, isVa ? 0.38 : 0.22); + ctx.fillRect(x, top, downW, h); + ctx.fillStyle = rgba(upColor, isVa ? 0.44 : 0.26); + ctx.fillRect(x + downW, top, upW, h); + } else if (mode === "delta") { + const delta = Number((profile.delta || [])[i] || 0); + const center = left + availableW / 2; + const dw = Math.max(1, Math.abs(delta) / maxTotal * (availableW / 2)); + ctx.fillStyle = delta >= 0 ? rgba(upColor, isVa ? 0.48 : 0.30) : rgba(downColor, isVa ? 0.46 : 0.28); + ctx.fillRect(delta >= 0 ? center : center - dw, top, dw, h); + } else { + ctx.fillStyle = rgba(color, isVa ? 0.42 : 0.22); + ctx.fillRect(x, top, w, h); + } + + if (isPoc) { + ctx.strokeStyle = rgba(pocColor, 0.95); + ctx.lineWidth = 1.25; + ctx.beginPath(); + ctx.moveTo(x, top + h / 2); + ctx.lineTo(right, top + h / 2); + ctx.stroke(); + } + } + ctx.strokeStyle = rgba(color, 0.55); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(right + 0.5, p.y); + ctx.lineTo(right + 0.5, p.y + p.h); + ctx.stroke(); + ctx.restore(); + return true; +} + +function volumeSlotWidth(view, xs, i, visibleCount) { + const x = Number(xs[i]); + const prev = i > 0 ? Number(xs[i - 1]) : NaN; + const next = i < xs.length - 1 ? Number(xs[i + 1]) : NaN; + let left = Number.isFinite(prev) + ? Math.abs(view._dataToScreenX(x) - view._dataToScreenX(prev)) + : NaN; + let right = Number.isFinite(next) + ? Math.abs(view._dataToScreenX(next) - view._dataToScreenX(x)) + : NaN; + const slot = Math.min( + Number.isFinite(left) && left > 0 ? left : Infinity, + Number.isFinite(right) && right > 0 ? right : Infinity + ); + if (Number.isFinite(slot)) return Math.max(1, Math.min(16, slot * 0.72)); + const pane = view.volumePane || view.plot; + return Math.max(1, Math.min(12, (pane.w / Math.max(visibleCount, 1)) * 0.72)); +} + +function drawVolumeBars(view, ctx, layer) { + const pane = view.volumePane; + const props = layer.props || {}; + const bars = props.bars; + if (!pane || !bars || !Array.isArray(bars.x) || !Array.isArray(bars.volume)) return; + const xs = bars.x; + const volume = bars.volume; + const direction = Array.isArray(bars.direction) ? bars.direction : []; + if (!xs.length || !volume.length) return; + const { x0, x1 } = view.view; + const visible = []; + let maxVol = 0; + for (let i = 0; i < Math.min(xs.length, volume.length); i++) { + const x = Number(xs[i]); + const vol = Number(volume[i]); + if (!Number.isFinite(x) || !Number.isFinite(vol) || vol < 0) continue; + if (x < x0 || x > x1) continue; + visible.push(i); + if (vol > maxVol) maxVol = vol; + } + if (!visible.length || maxVol <= 0) return; + + const upColor = view._layerColor(layer, "up_color", [0.13, 0.67, 0.58, 1]); + const downColor = view._layerColor(layer, "down_color", [0.95, 0.21, 0.27, 1]); + const gridColor = view._layerColor(layer, "grid_color", [0.56, 0.64, 0.76, 1]); + const labelColor = rgba(view._layerColor(layer, "label_color", [0.78, 0.82, 0.9, 1]), 0.78); + const pad = 3; + const maxH = Math.max(1, pane.h - pad * 2); + + ctx.save(); + ctx.fillStyle = "rgba(128,128,128,.035)"; + ctx.fillRect(pane.x, pane.y, pane.w, pane.h); + ctx.strokeStyle = rgba(gridColor, 0.22); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(pane.x, Math.round(pane.y) + 0.5); + ctx.lineTo(pane.x + pane.w, Math.round(pane.y) + 0.5); + ctx.moveTo(pane.x, Math.round(pane.y + pane.h / 2) + 0.5); + ctx.lineTo(pane.x + pane.w, Math.round(pane.y + pane.h / 2) + 0.5); + ctx.stroke(); + + for (const i of visible) { + const x = Number(xs[i]); + const vol = Number(volume[i]); + const cx = view._dataToScreenX(x); + if (cx < pane.x - 20 || cx > pane.x + pane.w + 20) continue; + const w = volumeSlotWidth(view, xs, i, visible.length); + const h = Math.max(1, (vol / maxVol) * maxH); + const y = pane.y + pane.h - h - pad; + const col = direction[i] ? upColor : downColor; + ctx.fillStyle = rgba(col, 0.54); + ctx.fillRect(cx - w / 2, y, w, h); + } + + ctx.fillStyle = labelColor; + ctx.font = "11px system-ui,sans-serif"; + ctx.fillText("Volume", pane.x + 4, pane.y + 13); + ctx.textAlign = "right"; + ctx.fillText(fmtLinear(maxVol, Math.max(maxVol / 2, 1)), pane.x + pane.w - 4, pane.y + 13); + ctx.restore(); +} + +function drawAnchoredStudy(view, ctx, layer) { + const anchor = layerAnchor(view, layer, "anchor") || layerAnchor(view, layer, "start"); + if (!anchor || !Number.isFinite(anchor.x)) return; + const p = view.plot; + const x = view._dataToScreenX(anchor.x); + const color = rgba(view._layerColor(layer, "color", [0.96, 0.68, 0.19, 1]), 1); + drawLine(ctx, x, p.y, x, p.y + p.h, color, 1.25, [4, 4]); + const label = layer.kind === "anchored_vwap" + ? "AVWAP" + : layer.kind === "anchored_volume_profile" + ? "AVP" + : "Volume profile"; + drawLabel(ctx, label, x + 6, p.y + 16, "#fff"); + if (layer.kind === "anchored_volume_profile") { + drawVolumeProfile(view, ctx, layer, { x0: x, x1: p.x + p.w, anchored: true }); + } +} + +function layerNumber(v) { + const n = Number(v); + if (Number.isFinite(n)) return n; + const t = Date.parse(v); + return Number.isFinite(t) ? t : NaN; +} + +function oscillatorLabel(layer) { + if (layer.id) return layer.id; + if (layer.kind === "rsi") return "RSI"; + if (layer.kind === "macd") return "MACD"; + if (layer.kind === "stochastic") return "Stoch"; + return layer.kind; +} + +function oscillatorRange(series, keys, fallback) { + let yMin = Number(series && series.y_min); + let yMax = Number(series && series.y_max); + const explicitRange = Number.isFinite(yMin) && Number.isFinite(yMax) && yMin !== yMax; + if (!explicitRange) { + yMin = Infinity; + yMax = -Infinity; + for (const key of keys) { + const arr = Array.isArray(series && series[key]) ? series[key] : []; + for (const raw of arr) { + const v = Number(raw); + if (!Number.isFinite(v)) continue; + yMin = Math.min(yMin, v); + yMax = Math.max(yMax, v); + } + } + if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) { + yMin = fallback[0]; + yMax = fallback[1]; + } + } + if (yMin === yMax) { + const pad = Math.abs(yMin) * 0.05 || 1; + yMin -= pad; + yMax += pad; + } + if (explicitRange) return [yMin, yMax]; + const pad = Math.max((yMax - yMin) * 0.05, 1e-9); + return [yMin - pad, yMax + pad]; +} + +function paneY(pane, value, yMin, yMax) { + return pane.y + (1 - (value - yMin) / (yMax - yMin)) * pane.h; +} + +function paneSlotWidth(view, pane, xs, i, visibleCount) { + const x = layerNumber(xs[i]); + const prev = i > 0 ? layerNumber(xs[i - 1]) : NaN; + const next = i < xs.length - 1 ? layerNumber(xs[i + 1]) : NaN; + let left = Number.isFinite(prev) + ? Math.abs(view._dataToScreenX(x) - view._dataToScreenX(prev)) + : NaN; + let right = Number.isFinite(next) + ? Math.abs(view._dataToScreenX(next) - view._dataToScreenX(x)) + : NaN; + const slot = Math.min( + Number.isFinite(left) && left > 0 ? left : Infinity, + Number.isFinite(right) && right > 0 ? right : Infinity + ); + if (Number.isFinite(slot)) return Math.max(1, Math.min(12, slot * 0.68)); + return Math.max(1, Math.min(10, (pane.w / Math.max(visibleCount, 1)) * 0.68)); +} + +function drawPaneFrame(view, ctx, layer, pane, yMin, yMax, label) { + const guides = Array.isArray(layer.props && layer.props.series && layer.props.series.guides) + ? layer.props.series.guides + : []; + const gridColor = view._layerColor(layer, "grid_color", [0.56, 0.64, 0.76, 1]); + const labelColor = rgba(view._layerColor(layer, "label_color", [0.32, 0.37, 0.46, 1]), 0.86); + ctx.save(); + ctx.fillStyle = "rgba(128,128,128,.026)"; + ctx.fillRect(pane.x, pane.y, pane.w, pane.h); + ctx.strokeStyle = rgba(gridColor, 0.20); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(pane.x, Math.round(pane.y) + 0.5); + ctx.lineTo(pane.x + pane.w, Math.round(pane.y) + 0.5); + for (const raw of guides) { + const g = Number(raw); + if (!Number.isFinite(g) || g < yMin || g > yMax) continue; + const y = Math.round(paneY(pane, g, yMin, yMax)) + 0.5; + ctx.moveTo(pane.x, y); + ctx.lineTo(pane.x + pane.w, y); + } + ctx.stroke(); + ctx.fillStyle = labelColor; + ctx.font = "11px system-ui,sans-serif"; + ctx.textAlign = "left"; + ctx.fillText(label, pane.x + 4, pane.y + 13); + ctx.textAlign = "right"; + ctx.fillText(fmtLinear(yMax, Math.max((yMax - yMin) / 2, 1)), pane.x + pane.w - 4, pane.y + 13); + ctx.fillText(fmtLinear(yMin, Math.max((yMax - yMin) / 2, 1)), pane.x + pane.w - 4, pane.y + pane.h - 4); + ctx.restore(); +} + +function drawPaneLine(view, ctx, pane, xs, values, yMin, yMax, color, width = 1.35) { + if (!Array.isArray(xs) || !Array.isArray(values) || xs.length < 2 || values.length < 2) return; + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.beginPath(); + let started = false; + const n = Math.min(xs.length, values.length); + for (let i = 0; i < n; i++) { + const x = view._dataToScreenX(layerNumber(xs[i])); + const value = Number(values[i]); + if (!Number.isFinite(x) || !Number.isFinite(value)) { + started = false; + continue; + } + const y = paneY(pane, value, yMin, yMax); + if (started) ctx.lineTo(x, y); + else { + ctx.moveTo(x, y); + started = true; + } + } + ctx.stroke(); + ctx.restore(); +} + +function drawMacdHistogram(view, ctx, layer, pane, series, yMin, yMax) { + const xs = Array.isArray(series.x) ? series.x : []; + const hist = Array.isArray(series.histogram) ? series.histogram : []; + if (!xs.length || !hist.length) return; + const upColor = view._layerColor(layer, "histogram_positive_color", [0.13, 0.67, 0.58, 1]); + const downColor = view._layerColor(layer, "histogram_negative_color", [0.95, 0.21, 0.27, 1]); + const zero = paneY(pane, 0, yMin, yMax); + let visible = 0; + for (let i = 0; i < Math.min(xs.length, hist.length); i++) { + const x = layerNumber(xs[i]); + const h = Number(hist[i]); + if (Number.isFinite(x) && Number.isFinite(h) && x >= view.view.x0 && x <= view.view.x1) visible++; + } + ctx.save(); + for (let i = 0; i < Math.min(xs.length, hist.length); i++) { + const x = view._dataToScreenX(layerNumber(xs[i])); + const h = Number(hist[i]); + if (!Number.isFinite(x) || !Number.isFinite(h)) continue; + const y = paneY(pane, h, yMin, yMax); + const w = paneSlotWidth(view, pane, xs, i, visible); + const top = Math.min(y, zero); + const height = Math.max(1, Math.abs(y - zero)); + ctx.fillStyle = rgba(h >= 0 ? upColor : downColor, 0.42); + ctx.fillRect(x - w / 2, top, w, height); + } + ctx.restore(); +} + +function drawPaneFilledLine(view, ctx, pane, xs, values, yMin, yMax, lineColor, fillColor, baseline = 0) { + if (!Array.isArray(xs) || !Array.isArray(values) || xs.length < 2 || values.length < 2) return; + const zero = paneY(pane, Math.max(yMin, Math.min(yMax, baseline)), yMin, yMax); + const pts = []; + const n = Math.min(xs.length, values.length); + for (let i = 0; i < n; i++) { + const x = view._dataToScreenX(layerNumber(xs[i])); + const value = Number(values[i]); + if (!Number.isFinite(x) || !Number.isFinite(value)) continue; + pts.push([x, paneY(pane, value, yMin, yMax)]); + } + if (pts.length < 2) return; + ctx.save(); + ctx.fillStyle = fillColor; + ctx.beginPath(); + ctx.moveTo(pts[0][0], zero); + for (const [x, y] of pts) ctx.lineTo(x, y); + ctx.lineTo(pts[pts.length - 1][0], zero); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = lineColor; + ctx.lineWidth = 1.25; + ctx.beginPath(); + pts.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); + ctx.stroke(); + ctx.restore(); +} + +function drawPerformancePane(view, ctx, layer) { + const pane = view._oscillatorPaneFor(layer); + const series = layer.props && layer.props.series; + if (!pane || !series || !Array.isArray(series.x)) return; + const [yMin, yMax] = oscillatorRange(series, ["drawdown_y"], [-1, 0]); + const label = series.drawdown_mode === "absolute" ? "Drawdown" : "Drawdown %"; + drawPaneFrame(view, ctx, layer, pane, yMin, yMax, label); + const color = view._layerColor(layer, "drawdown_color", [0.95, 0.21, 0.27, 1]); + drawPaneFilledLine( + view, + ctx, + pane, + series.x, + series.drawdown_y, + yMin, + yMax, + rgba(color, 0.92), + rgba(color, 0.20), + 0 + ); +} + +function drawReturnsDistribution(view, ctx, layer) { + const series = layer.props && layer.props.series; + if (!series || !Array.isArray(series.bin_edges) || !Array.isArray(series.y)) return; + const edges = series.bin_edges; + const y = series.y; + if (edges.length < 2 || !y.length) return; + const p = view.plot; + const barColor = view._layerColor(layer, "bar_color", [0.20, 0.40, 0.78, 1]); + const markerColor = view._layerColor(layer, "marker_color", [0.86, 0.19, 0.22, 1]); + ctx.save(); + ctx.fillStyle = rgba(barColor, Number(layer.style && layer.style.opacity) || 0.62); + ctx.strokeStyle = rgba(barColor, 0.88); + ctx.lineWidth = 1; + for (let i = 0; i < Math.min(y.length, edges.length - 1); i++) { + const x0 = view._dataToScreenX(Number(edges[i])); + const x1 = view._dataToScreenX(Number(edges[i + 1])); + const v = Number(y[i]); + if (!Number.isFinite(x0) || !Number.isFinite(x1) || !Number.isFinite(v) || v < 0) continue; + const left = Math.min(x0, x1) + 1; + const right = Math.max(x0, x1) - 1; + const top = view._dataToScreenY(v); + const base = view._dataToScreenY(0); + const w = Math.max(1, right - left); + const h = Math.max(1, base - top); + ctx.fillRect(left, top, w, h); + ctx.strokeRect(left, top, w, h); + } + ctx.restore(); + + const markers = Array.isArray(series.markers) ? series.markers : []; + for (const marker of markers) { + const x = view._dataToScreenX(Number(marker.x)); + if (!Number.isFinite(x)) continue; + drawLine(ctx, x, p.y, x, p.y + p.h, rgba(markerColor, 0.92), 1.25, [5, 4]); + drawLabel(ctx, marker.label || marker.role || "risk", x + 6, p.y + 18, "#fff", rgba(markerColor, 0.92)); + } +} + +function drawOscillatorPane(view, ctx, layer) { + const pane = view._oscillatorPaneFor(layer); + const series = layer.props && layer.props.series; + if (!pane || !series || !Array.isArray(series.x)) return; + if (layer.kind === "rsi") { + const [yMin, yMax] = oscillatorRange(series, ["rsi"], [0, 100]); + drawPaneFrame(view, ctx, layer, pane, yMin, yMax, oscillatorLabel(layer)); + const color = rgba(view._layerColor(layer, "color", [0.25, 0.46, 0.95, 1]), 0.98); + drawPaneLine(view, ctx, pane, series.x, series.rsi, yMin, yMax, color, Number(layer.style && layer.style.width) || 1.35); + return; + } + if (layer.kind === "macd") { + const [yMin, yMax] = oscillatorRange(series, ["macd", "signal", "histogram"], [-1, 1]); + drawPaneFrame(view, ctx, layer, pane, yMin, yMax, oscillatorLabel(layer)); + drawMacdHistogram(view, ctx, layer, pane, series, yMin, yMax); + const macdColor = rgba(view._layerColor(layer, "color", [0.25, 0.46, 0.95, 1]), 0.98); + const signalColor = rgba(view._layerColor(layer, "signal_color", [0.96, 0.62, 0.04, 1]), 0.96); + drawPaneLine(view, ctx, pane, series.x, series.macd, yMin, yMax, macdColor, Number(layer.style && layer.style.width) || 1.25); + drawPaneLine(view, ctx, pane, series.x, series.signal, yMin, yMax, signalColor, Number(layer.style && layer.style.signal_width) || 1.15); + return; + } + if (layer.kind === "stochastic") { + const [yMin, yMax] = oscillatorRange(series, ["k", "d"], [0, 100]); + drawPaneFrame(view, ctx, layer, pane, yMin, yMax, oscillatorLabel(layer)); + const kColor = rgba(view._layerColor(layer, "color", [0.25, 0.46, 0.95, 1]), 0.98); + const dColor = rgba(view._layerColor(layer, "signal_color", [0.96, 0.62, 0.04, 1]), 0.96); + drawPaneLine(view, ctx, pane, series.x, series.k, yMin, yMax, kColor, Number(layer.style && layer.style.width) || 1.25); + drawPaneLine(view, ctx, pane, series.x, series.d, yMin, yMax, dColor, Number(layer.style && layer.style.signal_width) || 1.15); + } +} + +function patternSlotWidth(xs, i) { + if (xs.length <= 1) return 7; + const prev = i > 0 ? xs[i] - xs[i - 1] : xs[1] - xs[0]; + const next = i < xs.length - 1 ? xs[i + 1] - xs[i] : prev; + const slot = Math.min(Math.abs(prev), Math.abs(next)); + return Math.max(3, Math.min(18, slot * 0.62)); +} + +function drawBarsPattern(view, ctx, layer) { + const props = layer.props || {}; + const pattern = props.pattern; + if (!pattern || !Array.isArray(pattern.x) || !pattern.x.length) { + drawRangeStudy(view, ctx, layer); + return; + } + const xs = pattern.x.map((v) => view._dataToScreenX(layerNumber(v))); + const open = pattern.open || []; + const high = pattern.high || []; + const low = pattern.low || []; + const close = pattern.close || []; + const color = view._layerColor(layer, "color", [0.56, 0.64, 0.76, 1]); + const upColor = view._layerColor(layer, "up_color", [0.13, 0.67, 0.58, 1]); + const downColor = view._layerColor(layer, "down_color", [0.95, 0.21, 0.27, 1]); + const wickColor = view._layerColor(layer, "wick_color", [0.58, 0.65, 0.76, 1]); + const mode = props.mode || "candlestick"; + const p = view.plot; + + ctx.save(); + ctx.globalAlpha = Number(layer.style && layer.style.opacity) || 0.74; + ctx.strokeStyle = rgba(color, 0.55); + ctx.setLineDash([4, 4]); + ctx.lineWidth = 1; + const firstX = xs.find((x) => Number.isFinite(x)); + if (Number.isFinite(firstX)) { + ctx.beginPath(); + ctx.moveTo(firstX, p.y); + ctx.lineTo(firstX, p.y + p.h); + ctx.stroke(); + } + ctx.setLineDash([]); + + if (mode === "line") { + ctx.strokeStyle = rgba(color, 0.95); + ctx.lineWidth = 1.5; + ctx.beginPath(); + let started = false; + for (let i = 0; i < xs.length; i++) { + const x = xs[i]; + const y = view._dataToScreenY(Number(close[i])); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + if (started) ctx.lineTo(x, y); + else { + ctx.moveTo(x, y); + started = true; + } + } + ctx.stroke(); + } else { + for (let i = 0; i < xs.length; i++) { + const x = xs[i]; + const o = Number(open[i]); + const h = Number(high[i]); + const l = Number(low[i]); + const c = Number(close[i]); + if (![x, o, h, l, c].every(Number.isFinite)) continue; + const yo = view._dataToScreenY(o); + const yh = view._dataToScreenY(h); + const yl = view._dataToScreenY(l); + const yc = view._dataToScreenY(c); + const up = c >= o; + const w = patternSlotWidth(xs, i); + ctx.strokeStyle = rgba(wickColor, 0.82); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, yh); + ctx.lineTo(x, yl); + ctx.stroke(); + const bodyTop = Math.min(yo, yc); + const bodyH = Math.max(1, Math.abs(yc - yo)); + if (mode === "ohlc") { + const col = up ? upColor : downColor; + ctx.strokeStyle = rgba(col, 0.95); + ctx.beginPath(); + ctx.moveTo(x - w / 2, yo); + ctx.lineTo(x, yo); + ctx.moveTo(x, yc); + ctx.lineTo(x + w / 2, yc); + ctx.stroke(); + } else if (up) { + ctx.fillStyle = "rgba(13,17,26,.78)"; + ctx.strokeStyle = rgba(upColor, 0.95); + ctx.fillRect(x - w / 2, bodyTop, w, bodyH); + ctx.strokeRect(x - w / 2, bodyTop, w, bodyH); + } else { + ctx.fillStyle = rgba(downColor, 0.82); + ctx.strokeStyle = rgba(downColor, 0.95); + ctx.fillRect(x - w / 2, bodyTop, w, bodyH); + ctx.strokeRect(x - w / 2, bodyTop, w, bodyH); + } + } + } + ctx.restore(); + + const anchor = layerAnchor(view, layer, "destination"); + const labelX = finitePoint(anchor) ? view._dataToScreenX(anchor.x) + 6 : (firstX || p.x) + 6; + const labelY = finitePoint(anchor) ? view._dataToScreenY(anchor.y) - 18 : p.y + 28; + drawLabel(ctx, "Bars pattern", labelX, labelY, "#fff", rgba(color, 0.78)); +} + +function drawRangeStudy(view, ctx, layer) { + const start = layerAnchor(view, layer, "start"); + const end = layerAnchor(view, layer, "end"); + if (!start || !end || !Number.isFinite(start.x) || !Number.isFinite(end.x)) return; + const p = view.plot; + const x0 = view._dataToScreenX(start.x); + const x1 = view._dataToScreenX(end.x); + const color = view._layerColor(layer, "color", [0.56, 0.64, 0.76, 1]); + ctx.save(); + ctx.fillStyle = rgba(color, 0.08); + ctx.fillRect(Math.min(x0, x1), p.y, Math.abs(x1 - x0), p.h); + ctx.restore(); + drawLine(ctx, x0, p.y, x0, p.y + p.h, rgba(color, 0.9), 1, [4, 4]); + drawLine(ctx, x1, p.y, x1, p.y + p.h, rgba(color, 0.9), 1, [4, 4]); + if (layer.kind === "fixed_range_volume_profile") { + drawVolumeProfile(view, ctx, layer, { x0, x1 }); + } + drawLabel(ctx, layer.kind === "fixed_range_volume_profile" ? "FRVP" : "Range", Math.max(x0, x1) + 6, p.y + 16, "#fff"); +} + +function drawPattern(view, ctx, layer) { + const labels = layer.kind === "xabcd_pattern" ? "XABCD" : "ABCD"; + const pts = []; + for (const label of labels) { + const p = layerAnchor(view, layer, label); + if (!finitePoint(p)) return; + pts.push({ label, x: view._dataToScreenX(p.x), y: view._dataToScreenY(p.y) }); + } + const color = rgba(view._layerColor(layer, "color", [0.64, 0.45, 0.95, 1]), 1); + ctx.save(); + ctx.strokeStyle = color; + ctx.fillStyle = "rgba(126,87,194,.10)"; + ctx.lineWidth = 1.5; + ctx.beginPath(); + pts.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); + ctx.stroke(); + if (pts.length > 3) { + ctx.lineTo(pts[0].x, pts[0].y); + ctx.fill(); + } + ctx.restore(); + for (const p of pts) { + drawHandle(ctx, p.x, p.y, color); + drawLabel(ctx, p.label, p.x + 6, p.y - 8, "#fff"); + } +} + +function drawGhostFeed(view, ctx, layer) { + const anchor = layerAnchor(view, layer, "anchor"); + if (!finitePoint(anchor)) return; + const p = view.plot; + const props = layer.props || {}; + const feed = props.feed; + if (feed && Array.isArray(feed.x) && feed.x.length) { + const xs = feed.x.map((v) => view._dataToScreenX(layerNumber(v))); + const open = feed.open || []; + const high = feed.high || []; + const low = feed.low || []; + const close = feed.close || []; + const color = view._layerColor(layer, "color", [0.58, 0.67, 0.8, 1]); + const upColor = view._layerColor(layer, "up_color", [0.56, 0.72, 0.86, 1]); + const downColor = view._layerColor(layer, "down_color", [0.84, 0.48, 0.57, 1]); + const wickColor = view._layerColor(layer, "wick_color", [0.58, 0.67, 0.8, 1]); + ctx.save(); + ctx.globalAlpha = Number(layer.style && layer.style.opacity) || 0.42; + ctx.strokeStyle = rgba(color, 0.78); + ctx.setLineDash([5, 4]); + ctx.lineWidth = 1; + const firstX = xs.find((x) => Number.isFinite(x)); + if (Number.isFinite(firstX)) { + ctx.beginPath(); + ctx.moveTo(firstX, p.y); + ctx.lineTo(firstX, p.y + p.h); + ctx.stroke(); + } + ctx.setLineDash([]); + for (let i = 0; i < xs.length; i++) { + const x = xs[i]; + const o = Number(open[i]); + const h = Number(high[i]); + const l = Number(low[i]); + const c = Number(close[i]); + if (![x, o, h, l, c].every(Number.isFinite)) continue; + const yo = view._dataToScreenY(o); + const yh = view._dataToScreenY(h); + const yl = view._dataToScreenY(l); + const yc = view._dataToScreenY(c); + const up = c >= o; + const w = patternSlotWidth(xs, i); + ctx.strokeStyle = rgba(wickColor, 0.78); + ctx.beginPath(); + ctx.moveTo(x, yh); + ctx.lineTo(x, yl); + ctx.stroke(); + const bodyTop = Math.min(yo, yc); + const bodyH = Math.max(1, Math.abs(yc - yo)); + const bodyColor = up ? upColor : downColor; + ctx.fillStyle = up ? rgba(bodyColor, 0.20) : rgba(bodyColor, 0.36); + ctx.strokeStyle = rgba(bodyColor, 0.82); + ctx.fillRect(x - w / 2, bodyTop, w, bodyH); + ctx.strokeRect(x - w / 2, bodyTop, w, bodyH); + } + ctx.restore(); + drawLabel(ctx, "Ghost feed", view._dataToScreenX(anchor.x) + 6, view._dataToScreenY(anchor.y) - 18, "#fff", rgba(color, 0.72)); + return; + } + const bars = Math.min(48, Math.max(1, Number(props.bars || 12))); + const dx = Math.max(5, p.w / 80); + const dir = props.direction === "down" ? 1 : -1; + const color = view._layerColor(layer, "color", [0.58, 0.67, 0.8, 1]); + let x = view._dataToScreenX(anchor.x); + let y = view._dataToScreenY(anchor.y); + ctx.save(); + ctx.strokeStyle = rgba(color, 0.55); + ctx.fillStyle = rgba(color, 0.16); + ctx.lineWidth = 1; + for (let i = 0; i < bars; i++) { + const bodyH = 8 + (i % 5); + const wickH = bodyH + 8; + const cx = x + (i + 1) * dx; + const cy = y + dir * i * 1.8 + Math.sin(i * 0.8) * 6; + ctx.beginPath(); + ctx.moveTo(cx, cy - wickH / 2); + ctx.lineTo(cx, cy + wickH / 2); + ctx.stroke(); + ctx.fillRect(cx - 2.5, cy - bodyH / 2, 5, bodyH); + ctx.strokeRect(cx - 2.5, cy - bodyH / 2, 5, bodyH); + } + ctx.restore(); + drawLabel(ctx, "Ghost feed", x + dx, y - 18, "#fff"); +} + +export const LAYER_KINDS = { + position: { draw: drawPosition }, + long_position: { draw: drawPosition }, + short_position: { draw: drawPosition }, + position_forecast: { draw: drawProjection }, + sector: { draw: drawSector }, + anchored_vwap: { draw: drawAnchoredStudy }, + vwap: { draw: () => {} }, + bollinger_bands: { draw: () => {} }, + anchored_volume_profile: { draw: drawAnchoredStudy }, + fixed_range_volume_profile: { draw: drawRangeStudy }, + price_range: { draw: drawRangeStudy }, + date_range: { draw: drawRangeStudy }, + date_price_range: { draw: drawRangeStudy }, + bars_pattern: { draw: drawBarsPattern }, + ghost_feed: { draw: drawGhostFeed }, + abcd_pattern: { draw: drawPattern }, + xabcd_pattern: { draw: drawPattern }, + volume_bars: { draw: drawVolumeBars }, + equity_drawdown: { draw: drawPerformancePane }, + rsi: { draw: drawOscillatorPane }, + macd: { draw: drawOscillatorPane }, + stochastic: { draw: drawOscillatorPane }, + moving_average: { draw: () => {} }, + returns_distribution: { draw: drawReturnsDistribution }, +}; + +export function layerOf(kind) { + return LAYER_KINDS[kind] || { draw: () => {} }; +} diff --git a/js/src/60_entries.ts b/js/src/60_entries.ts index 0c780c99..bb7cb653 100644 --- a/js/src/60_entries.ts +++ b/js/src/60_entries.ts @@ -1,6 +1,7 @@ import { bytesToSpan, decodeFrame, payloadBuffers, payloadCoherent } from "./00_header"; import { ChartView } from "./50_chartview"; import { MARK_KINDS, markOf } from "./55_marks"; +import { LAYER_KINDS, layerOf } from "./57_layers"; // Prototype-augmentation modules: imported for their side effect of attaching // methods to ChartView.prototype. Every entry point must load them before the // first ChartView is constructed. @@ -96,5 +97,5 @@ export function renderStandalone(el, spec, arrayBuffer) { // Public API. The ESM bundle (static/index.js, anywidget's `_esm`) re-exports // these directly; the IIFE bundle (static/standalone.js) exposes the same // namespace as `window.xy`. -export { decodeFrame, ChartView, MARK_KINDS, markOf }; +export { decodeFrame, ChartView, MARK_KINDS, markOf, LAYER_KINDS, layerOf }; export default { render, decodeFrame }; diff --git a/python/xy/__init__.py b/python/xy/__init__.py index 6ac39be4..8f240b07 100644 --- a/python/xy/__init__.py +++ b/python/xy/__init__.py @@ -134,6 +134,67 @@ "violin_chart": ".components", } +_EXPORTS.update( + { + "candlestick": ".components", + "candlestick_chart": ".components", + "ohlc": ".components", + "ohlc_chart": ".components", + **{ + name: ".finance" + for name in ( + "FinanceChart", + "FinanceLayer", + "FinanceTools", + "Instrument", + "Layer", + "PositionDrawing", + "abcd_pattern", + "anchored_volume_profile", + "anchored_vwap", + "anchored_vwap_values", + "bars_pattern", + "bollinger_bands", + "bollinger_bands_values", + "date_price_range", + "date_range", + "drawdown_values", + "equity_curve_values", + "equity_drawdown", + "finance_chart", + "finance_tools", + "fixed_range_volume_profile", + "ghost_feed", + "instrument", + "long_position", + "macd", + "macd_values", + "moving_average", + "moving_average_values", + "performance_chart", + "position_forecast", + "price_range", + "returns_distribution", + "returns_distribution_chart", + "returns_distribution_values", + "returns_values", + "rsi", + "rsi_values", + "sector", + "short_position", + "stochastic", + "stochastic_values", + "var_cvar_values", + "volume_bars", + "volume_profile_values", + "vwap", + "vwap_values", + "xabcd_pattern", + ) + }, + } +) + __all__ = [ "CHART_DOM_SLOTS", "Animation", @@ -239,6 +300,62 @@ "y_band", ] +__all__.extend( + [ + "FinanceChart", + "FinanceLayer", + "FinanceTools", + "Instrument", + "Layer", + "PositionDrawing", + "abcd_pattern", + "anchored_volume_profile", + "anchored_vwap", + "anchored_vwap_values", + "bars_pattern", + "bollinger_bands", + "bollinger_bands_values", + "candlestick", + "candlestick_chart", + "date_price_range", + "date_range", + "drawdown_values", + "equity_curve_values", + "equity_drawdown", + "finance_chart", + "finance_tools", + "fixed_range_volume_profile", + "ghost_feed", + "instrument", + "long_position", + "macd", + "macd_values", + "moving_average", + "moving_average_values", + "ohlc", + "ohlc_chart", + "performance_chart", + "position_forecast", + "price_range", + "returns_distribution", + "returns_distribution_chart", + "returns_distribution_values", + "returns_values", + "rsi", + "rsi_values", + "sector", + "short_position", + "stochastic", + "stochastic_values", + "var_cvar_values", + "volume_bars", + "volume_profile_values", + "vwap", + "vwap_values", + "xabcd_pattern", + ] +) + def _load_export(name: str) -> Any: module_name = _EXPORTS.get(name) diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 4c0f92cc..dfb16f66 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -584,6 +584,8 @@ def _rollback(self, checkpoint: _FigureCheckpoint) -> None: # — one body, one signature, one set of defaults for both dialects. line = _marks.line area = _marks.area + candlestick = _marks.candlestick + ohlc = _marks.ohlc scatter = _marks.scatter histogram = _marks.histogram hist = _marks.hist @@ -1725,6 +1727,8 @@ def _range_columns(self, t: Trace, axis_id: str) -> list[Column]: return [] if axis == "y" and t.y_axis != axis_id: return [] + if t.open_ is not None and t.high is not None and t.low is not None and t.close is not None: + return [t.x] if axis == "x" else [t.low, t.high] if t.kind in {"area", "error_band"} and t.base is not None: return [t.x] if axis == "x" else [t.y, t.base] if ( diff --git a/python/xy/_payload.py b/python/xy/_payload.py index 9448c189..c46e79b7 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -161,6 +161,15 @@ def ship_values( encoded = lod.encode_f32_values(vals, offset, lo, hi, kind=kind) return self._append(encoded.values, encoded.meta) + def ship_at( + self, values: np.ndarray, *, offset: float, scale: float, kind: str = "float" + ) -> int: + """Offset-encode a column against an explicit shared axis offset — + candlestick ships open/high/low/close in one shared y frame so wick + and body geometry stay consistent after f32 encoding (§4).""" + enc = kernels.encode_f32(values, offset, scale) + return self._append(enc, {"offset": offset, "scale": scale, "kind": kind}) + def _append(self, enc: np.ndarray, meta: dict[str, Any]) -> int: # Retain the encoded array until blob assembly so each column is copied # once into the final bytes object, rather than once in `tobytes()` and @@ -970,6 +979,59 @@ def _emit_rect( self._ship_trace_styles(entry, t, sel_arg, pw) return self._transition_entry(entry, t, pw, sel_arg) + def _emit_candlestick( + self, t: Trace, pw: "_PayloadWriter", xr: tuple, yr: tuple, px_width: int + ) -> dict[str, Any]: + del yr + if t.open_ is None or t.high is None or t.low is None or t.close is None: + raise ValueError(f"{t.kind} trace missing OHLC columns") + x = t.x.values + o, h, low, c = t.open_.values, t.high.values, t.low.values, t.close.values + tier = "direct" + decimator = getattr(kernels, "ohlc_decimate", None) + if t.n_points > DECIMATION_THRESHOLD and callable(decimator): + xd, od, hd, ld, cd = decimator( + x, o, h, low, c, xr[0], xr[1] + np.finfo(np.float64).eps, px_width + ) + if len(xd): + x, o, h, low, c = xd, od, hd, ld, cd + else: + x, o, h, low, c = x[:0], o[:0], h[:0], low[:0], c[:0] + tier = "decimated" + finite = ( + np.isfinite(x) & np.isfinite(o) & np.isfinite(h) & np.isfinite(low) & np.isfinite(c) + ) + if len(x) and not bool(np.all(finite)): + x, o, h, low, c = x[finite], o[finite], h[finite], low[finite], c[finite] + + # One shared y frame for all four price columns (§4). + y_lo, y_hi = t.low.min, t.high.max + if np.isfinite(y_lo) and np.isfinite(y_hi): + y_off = (y_lo + y_hi) / 2.0 + y_scale = lod.f32_safe_scale(y_off, y_lo, y_hi) + else: + y_off = 0.0 + y_scale = 1.0 + y_kind = t.close.kind + return { + "id": t.id, + "kind": t.kind, + "name": t.name, + "style": dict(t.style), + "tier": tier, + "n_points": t.n_points, + "n_marks": int(len(x)), + "x_axis": t.x_axis, + "y_axis": t.y_axis, + "x": pw.ship(x, t.x, scale=self._axis_scale(t.x_axis)), + "open": pw.ship_at(o, offset=y_off, scale=y_scale, kind=y_kind), + "high": pw.ship_at(h, offset=y_off, scale=y_scale, kind=y_kind), + "low": pw.ship_at(low, offset=y_off, scale=y_scale, kind=y_kind), + "close": pw.ship_at(c, offset=y_off, scale=y_scale, kind=y_kind), + } + + _emit_ohlc = _emit_candlestick + def _emit_bar_compact( self, t: Trace, pw: "_PayloadWriter", xr: tuple, yr: tuple, px_width: int ) -> dict[str, Any]: diff --git a/python/xy/_trace.py b/python/xy/_trace.py index f26805db..e3ec1cee 100644 --- a/python/xy/_trace.py +++ b/python/xy/_trace.py @@ -26,6 +26,12 @@ class Trace: # Area-style marks keep an explicit baseline column; rectangle-like marks # use x0/x1/y0/y1 below. base: Optional[Column] = None + # Finance marks keep one canonical column per OHLC value. ``y`` mirrors + # close for shared point bookkeeping; autorange uses low/high instead. + open_: Optional[Column] = None + high: Optional[Column] = None + low: Optional[Column] = None + close: Optional[Column] = None # Grid-like marks (heatmap/image) ship one scalar grid plus metadata instead # of four rectangle columns per cell. grid: Optional[Column] = None diff --git a/python/xy/components.py b/python/xy/components.py index a5ec5b9c..04cac63a 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -87,6 +87,8 @@ "box", "box_chart", "callout", + "candlestick", + "candlestick_chart", "chart", "colorbar", "column", @@ -117,6 +119,8 @@ "mark", "marker", "modebar", + "ohlc", + "ohlc_chart", "pie_chart", "polar_bar_chart", "polar_chart", @@ -189,6 +193,7 @@ class Mark(Component): y: Any = None # column name or ArrayLike (typed on the mark factories) data: TableLike = None name: Optional[str] = None + id: Optional[str] = None class_name: Optional[str] = None style: dict[str, StyleValue] = field(default_factory=dict) key: Any = None @@ -825,6 +830,106 @@ def area( ) +def candlestick( + x: Union[str, ArrayLike, None] = None, + open: Union[str, ArrayLike, None] = None, # noqa: A002 - OHLC domain naming + high: Union[str, ArrayLike, None] = None, + low: Union[str, ArrayLike, None] = None, + close: Union[str, ArrayLike, None] = None, + *, + volume: Union[str, ArrayLike, None] = None, + data: TableLike = None, + name: Optional[str] = None, + id: Optional[str] = None, + up_color: str = "#26a69a", + down_color: str = "#ef5350", + width_frac: float = 0.7, + opacity: float = 1.0, + hollow: bool = False, + wick_color: Optional[str] = None, + class_name: Optional[str] = None, + key: Any = None, + animation: Animation | bool | None = None, + x_axis: str = "x", + y_axis: str = "y", +) -> Mark: + """An OHLC candlestick series.""" + return Mark( + kind="candlestick", + x=x, + y=close, + data=data, + name=name, + id=id, + class_name=class_name, + key=key, + animation=animation, + props={ + "open": open, + "high": high, + "low": low, + "close": close, + "volume": volume, + "up_color": up_color, + "down_color": down_color, + "width_frac": width_frac, + "opacity": opacity, + "hollow": hollow, + "wick_color": wick_color, + "x_axis": _axis_id(x_axis, "candlestick x_axis"), + "y_axis": _axis_id(y_axis, "candlestick y_axis"), + }, + ) + + +def ohlc( + x: Union[str, ArrayLike, None] = None, + open: Union[str, ArrayLike, None] = None, # noqa: A002 - OHLC domain naming + high: Union[str, ArrayLike, None] = None, + low: Union[str, ArrayLike, None] = None, + close: Union[str, ArrayLike, None] = None, + *, + volume: Union[str, ArrayLike, None] = None, + data: TableLike = None, + name: Optional[str] = None, + id: Optional[str] = None, + up_color: str = "#26a69a", + down_color: str = "#ef5350", + width_frac: float = 0.7, + opacity: float = 1.0, + class_name: Optional[str] = None, + key: Any = None, + animation: Animation | bool | None = None, + x_axis: str = "x", + y_axis: str = "y", +) -> Mark: + """An OHLC bar series.""" + return Mark( + kind="ohlc", + x=x, + y=close, + data=data, + name=name, + id=id, + class_name=class_name, + key=key, + animation=animation, + props={ + "open": open, + "high": high, + "low": low, + "close": close, + "volume": volume, + "up_color": up_color, + "down_color": down_color, + "width_frac": width_frac, + "opacity": opacity, + "x_axis": _axis_id(x_axis, "ohlc x_axis"), + "y_axis": _axis_id(y_axis, "ohlc y_axis"), + }, + ) + + def error_band( x: Union[str, ArrayLike, None] = None, lower: Union[str, ArrayLike, None] = None, @@ -5741,6 +5846,38 @@ def _apply_area(fig: Figure, m: Mark, data: Any) -> None: ) +def _apply_candlestick(fig: Figure, m: Mark, data: Any) -> None: + fig.candlestick( + _resolve_axis_values(fig, data, m.x, "x", f"{m.kind}.x"), + _resolve(data, m.props["open"], context=f"{m.kind}.open"), + _resolve(data, m.props["high"], context=f"{m.kind}.high"), + _resolve(data, m.props["low"], context=f"{m.kind}.low"), + _resolve(data, m.props["close"], context=f"{m.kind}.close"), + name=m.name, + up_color=m.props["up_color"], + down_color=m.props["down_color"], + width_frac=m.props["width_frac"], + opacity=m.props["opacity"], + hollow=m.props["hollow"], + wick_color=m.props["wick_color"], + ) + + +def _apply_ohlc(fig: Figure, m: Mark, data: Any) -> None: + fig.ohlc( + _resolve_axis_values(fig, data, m.x, "x", f"{m.kind}.x"), + _resolve(data, m.props["open"], context=f"{m.kind}.open"), + _resolve(data, m.props["high"], context=f"{m.kind}.high"), + _resolve(data, m.props["low"], context=f"{m.kind}.low"), + _resolve(data, m.props["close"], context=f"{m.kind}.close"), + name=m.name, + up_color=m.props["up_color"], + down_color=m.props["down_color"], + width_frac=m.props["width_frac"], + opacity=m.props["opacity"], + ) + + def _apply_error_band(fig: Figure, m: Mark, data: Any) -> None: fig.error_band( _resolve_axis_values(fig, data, m.x, "x", f"{m.kind}.x"), @@ -6187,6 +6324,7 @@ def _apply_callout_annotation(fig: Figure, annotation: Annotation) -> None: "bar": _apply_bar, "box": _apply_box, "column": _apply_column, + "candlestick": _apply_candlestick, "contour": _apply_contour, "ecdf": _apply_ecdf, "errorbar": _apply_errorbar, @@ -6197,6 +6335,7 @@ def _apply_callout_annotation(fig: Figure, annotation: Annotation) -> None: "scatter": _apply_scatter, "segments": _apply_segments, "line": _apply_line, + "ohlc": _apply_ohlc, "step": _apply_step, "stairs": _apply_stairs, "stem": _apply_stem, @@ -6373,6 +6512,16 @@ def line_chart(*children: Component, **props: Any) -> Chart: return Chart("line_chart", children, **props) +def candlestick_chart(*children: Component, **props: Any) -> Chart: + """A candlestick chart composing ``candlestick`` marks.""" + return Chart("candlestick_chart", children, **props) + + +def ohlc_chart(*children: Component, **props: Any) -> Chart: + """An OHLC bar chart composing ``ohlc`` marks.""" + return Chart("ohlc_chart", children, **props) + + def _require_polar_coords(props: dict) -> None: """Pin `coords` to polar, refusing an explicit override. diff --git a/python/xy/finance.py b/python/xy/finance.py new file mode 100644 index 00000000..3d0df596 --- /dev/null +++ b/python/xy/finance.py @@ -0,0 +1,2539 @@ +"""Finance overlay/study/drawing API foundation. + +This module deliberately does not teach `candlestick()` about every trading +tool. Candles stay a fast OHLC mark; finance-specific behavior is modeled as +small, serializable layers that can be composed over the same axes. The WebGL +editor/renderer can consume this layer spec later without changing the mark +payload contract. +""" + +from __future__ import annotations + +import math +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from typing import Any, Optional + +import numpy as np + +from . import export +from .components import Axis, Chart, Component, Legend, Mark, _resolve, x_axis, y_axis + + +def _jsonable(value: Any) -> Any: + """Return a stable JSON-shaped value without importing heavy serializers.""" + if hasattr(value, "to_spec") and callable(value.to_spec): + return value.to_spec() + if hasattr(value, "isoformat") and callable(value.isoformat): + return value.isoformat() + if isinstance(value, np.ndarray): + return [_jsonable(v) for v in value.tolist()] + if isinstance(value, np.bool_): + return bool(value) + if isinstance(value, Mapping): + return {str(k): _jsonable(v) for k, v in value.items() if v is not None} + if isinstance(value, (list, tuple)): + return [_jsonable(v) for v in value] + if hasattr(value, "item") and callable(value.item): + return _jsonable(value.item()) + return value + + +def _anchor(value: Any) -> dict[str, Any]: + """Normalize common finance anchor shorthands to data/bar/price coords. + + - `(x, y)` -> exact data coordinate + - `{"x": ..., "y": ...}` -> passed through + - number -> price-only anchor + - anything else -> x-only anchor + """ + if value is None: + return {} + if isinstance(value, Mapping): + return _jsonable(value) + if isinstance(value, tuple): + if len(value) == 2: + return {"x": _jsonable(value[0]), "y": _jsonable(value[1])} + if len(value) == 3: + return {"x": _jsonable(value[0]), "y": _jsonable(value[1]), "bar": _jsonable(value[2])} + raise ValueError("anchor tuples must be (x, y) or (x, y, bar)") + if isinstance(value, (int, float)): + return {"y": float(value)} + return {"x": _jsonable(value)} + + +def _bar_anchor(value: Any) -> dict[str, Any]: + if isinstance(value, int) and not isinstance(value, bool): + return {"bar": int(value)} + return _anchor(value) + + +def _axis_values(x: Any) -> np.ndarray: + arr = np.asarray(x) + if np.issubdtype(arr.dtype, np.datetime64): + return arr.astype("datetime64[ms]").astype(np.float64) + return arr.astype(np.float64) + + +def _anchor_x_value(value: Any, x_values: np.ndarray) -> Optional[float]: + anchor = _anchor(value) + if "bar" in anchor: + return None + raw = anchor.get("x") + if raw is None: + return None + try: + return float(raw) + except (TypeError, ValueError): + # Date-like anchors follow the x-axis dtype convention: milliseconds + # since epoch for datetime64 series. + try: + return float(np.datetime64(raw, "ms").astype("int64")) + except (TypeError, ValueError): + return None + + +def _anchor_index(x: Any, anchor: Any, n: int) -> int: + spec = _anchor(anchor) + if "bar" in spec: + idx = int(spec["bar"]) + else: + xv = _axis_values(x) + ax = _anchor_x_value(anchor, xv) + idx = 0 if ax is None else int(np.searchsorted(xv, ax, side="left")) + return min(max(idx, 0), max(n - 1, 0)) + + +def _anchor_slice_index(x: Any, anchor: Any, n: int, *, default: int, side: str) -> int: + spec = _anchor(anchor) + if "bar" in spec: + idx = int(spec["bar"]) + (1 if side == "right" else 0) + else: + xv = _axis_values(x) + ax = _anchor_x_value(anchor, xv) + idx = default if ax is None else int(np.searchsorted(xv, ax, side=side)) # ty: ignore[no-matching-overload] + return min(max(idx, 0), n) + + +def _price_source( + price: str, + open_: np.ndarray, + high: np.ndarray, + low: np.ndarray, + close: np.ndarray, +) -> np.ndarray: + if price in {"hlc3", "typical"}: + return (high + low + close) / 3.0 + if price == "close": + return close + if price == "ohlc4": + return (open_ + high + low + close) / 4.0 + if price == "open": + return open_ + if price == "high": + return high + if price == "low": + return low + raise ValueError( + "price must be one of 'hlc3', 'typical', 'close', 'ohlc4', 'open', 'high', 'low'" + ) + + +def _anchored_vwap_arrays( + x: Any, + open_: Any, + high: Any, + low: Any, + close: Any, + volume: Any, + *, + anchor: Any, + price: str = "hlc3", +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + arrays = [np.asarray(v) for v in (x, open_, high, low, close, volume)] + n = len(arrays[0]) + if any(len(a) != n for a in arrays): + raise ValueError("anchored VWAP x/open/high/low/close/volume must have equal length") + if n == 0: + return arrays[0], np.asarray([], dtype=np.float64), np.asarray([], dtype=np.float64) + x_arr = arrays[0] + open_f, high_f, low_f, close_f, volume_f = [np.asarray(a, dtype=np.float64) for a in arrays[1:]] + start = _anchor_index(x_arr, anchor, n) + px = _price_source(price, open_f, high_f, low_f, close_f)[start:] + vol = volume_f[start:] + if np.any(vol < 0): + raise ValueError("anchored VWAP volume must be non-negative") + finite = np.isfinite(px) & np.isfinite(vol) + weight = np.where(finite, vol, 0.0) + cum_vol = np.cumsum(weight) + cum_pv = np.cumsum(np.where(finite, px * vol, 0.0)) + with np.errstate(divide="ignore", invalid="ignore"): + vwap = cum_pv / cum_vol + second = np.cumsum(np.where(finite, px * px * vol, 0.0)) / cum_vol + variance = np.maximum(second - vwap * vwap, 0.0) + std = np.sqrt(variance) + vwap[cum_vol <= 0] = np.nan + std[cum_vol <= 0] = np.nan + return x_arr[start:], vwap, std + + +def anchored_vwap_values( + x: Any, + open: Any, # noqa: A002 - OHLC domain naming + high: Any, + low: Any, + close: Any, + volume: Any, + *, + anchor: Any, + price: str = "hlc3", +) -> tuple[np.ndarray, np.ndarray]: + """Compute anchored VWAP values from OHLCV arrays. + + Returns `(x_from_anchor, vwap)`. This is the deterministic Python-side + reference used by the composed finance study; native acceleration can be + added underneath without changing the API. + """ + xs, vwap, _ = _anchored_vwap_arrays( + x, open, high, low, close, volume, anchor=anchor, price=price + ) + return xs, vwap + + +def vwap_values( + x: Any, + open: Any, # noqa: A002 - OHLC domain naming + high: Any, + low: Any, + close: Any, + volume: Any, + *, + price: str = "hlc3", +) -> tuple[np.ndarray, np.ndarray]: + """Compute cumulative VWAP values from the first bar.""" + xs, vwap, _ = _anchored_vwap_arrays( + x, open, high, low, close, volume, anchor={"bar": 0}, price=price + ) + return xs, vwap + + +def moving_average_values(values: Any, *, window: int = 20, method: str = "sma") -> np.ndarray: + """Compute a simple or exponential moving average. + + SMA values are `nan` until a full window is available. EMA values start at + the first input and use the standard `2 / (window + 1)` smoothing factor. + """ + if window <= 0: + raise ValueError("window must be positive") + if method not in {"sma", "ema"}: + raise ValueError("method must be 'sma' or 'ema'") + values_f = np.asarray(values, dtype=np.float64) + if values_f.ndim != 1: + raise ValueError("values must be one-dimensional") + out = np.full(len(values_f), np.nan, dtype=np.float64) + finite = np.isfinite(values_f) + if len(values_f) == 0 or not finite.any(): + return out + if method == "sma": + valid_values = np.where(finite, values_f, 0.0) + valid_counts = np.cumsum(finite.astype(np.int64)) + csum = np.cumsum(valid_values) + for i in range(window - 1, len(values_f)): + count = valid_counts[i] - (valid_counts[i - window] if i >= window else 0) + if count == window: + total = csum[i] - (csum[i - window] if i >= window else 0.0) + out[i] = total / window + return out + + alpha = 2.0 / (window + 1.0) + first = int(np.flatnonzero(finite)[0]) + out[first] = values_f[first] + prev = out[first] + for i in range(first + 1, len(values_f)): + if not finite[i]: + out[i] = prev + continue + prev = alpha * values_f[i] + (1.0 - alpha) * prev + out[i] = prev + return out + + +def bollinger_bands_values( + values: Any, + *, + window: int = 20, + deviations: float = 2.0, +) -> dict[str, np.ndarray]: + """Compute Bollinger middle/upper/lower bands using rolling population std.""" + if window <= 0: + raise ValueError("window must be positive") + if not math.isfinite(deviations) or deviations <= 0: + raise ValueError("deviations must be positive") + values_f = np.asarray(values, dtype=np.float64) + if values_f.ndim != 1: + raise ValueError("values must be one-dimensional") + middle = moving_average_values(values_f, window=window, method="sma") + std = np.full(len(values_f), np.nan, dtype=np.float64) + finite = np.isfinite(values_f) + for i in range(window - 1, len(values_f)): + chunk = values_f[i - window + 1 : i + 1] + if finite[i - window + 1 : i + 1].all(): + std[i] = float(np.std(chunk, ddof=0)) + return { + "middle": middle, + "upper": middle + deviations * std, + "lower": middle - deviations * std, + "std": std, + } + + +def rsi_values(values: Any, *, window: int = 14) -> np.ndarray: + """Compute Wilder RSI in the 0-100 range.""" + if window <= 0: + raise ValueError("window must be positive") + values_f = np.asarray(values, dtype=np.float64) + if values_f.ndim != 1: + raise ValueError("values must be one-dimensional") + out = np.full(len(values_f), np.nan, dtype=np.float64) + if len(values_f) <= window or not np.all(np.isfinite(values_f)): + return out + delta = np.diff(values_f) + gain = np.maximum(delta, 0.0) + loss = np.maximum(-delta, 0.0) + avg_gain = float(np.mean(gain[:window])) + avg_loss = float(np.mean(loss[:window])) + + def score(gain_value: float, loss_value: float) -> float: + if loss_value == 0.0 and gain_value == 0.0: + return 50.0 + if loss_value == 0.0: + return 100.0 + return 100.0 - 100.0 / (1.0 + gain_value / loss_value) + + out[window] = score(avg_gain, avg_loss) + for i in range(window + 1, len(values_f)): + avg_gain = (avg_gain * (window - 1) + gain[i - 1]) / window + avg_loss = (avg_loss * (window - 1) + loss[i - 1]) / window + out[i] = score(avg_gain, avg_loss) + return out + + +def macd_values( + values: Any, + *, + fast: int = 12, + slow: int = 26, + signal: int = 9, +) -> dict[str, np.ndarray]: + """Compute MACD line, signal line, and histogram.""" + if fast <= 0 or slow <= 0 or signal <= 0: + raise ValueError("fast, slow, and signal must be positive") + if fast >= slow: + raise ValueError("fast must be less than slow") + values_f = np.asarray(values, dtype=np.float64) + if values_f.ndim != 1: + raise ValueError("values must be one-dimensional") + fast_ema = moving_average_values(values_f, window=fast, method="ema") + slow_ema = moving_average_values(values_f, window=slow, method="ema") + macd = fast_ema - slow_ema + signal_line = moving_average_values(macd, window=signal, method="ema") + return {"macd": macd, "signal": signal_line, "histogram": macd - signal_line} + + +def stochastic_values( + high: Any, + low: Any, + close: Any, + *, + k_window: int = 14, + d_window: int = 3, +) -> dict[str, np.ndarray]: + """Compute stochastic oscillator %K and %D in the 0-100 range.""" + if k_window <= 0 or d_window <= 0: + raise ValueError("k_window and d_window must be positive") + high_f = np.asarray(high, dtype=np.float64) + low_f = np.asarray(low, dtype=np.float64) + close_f = np.asarray(close, dtype=np.float64) + if high_f.ndim != 1 or low_f.ndim != 1 or close_f.ndim != 1: + raise ValueError("high/low/close must be one-dimensional") + n = len(high_f) + if len(low_f) != n or len(close_f) != n: + raise ValueError("high/low/close must have equal length") + k = np.full(n, np.nan, dtype=np.float64) + finite = np.isfinite(high_f) & np.isfinite(low_f) & np.isfinite(close_f) + for i in range(k_window - 1, n): + window_slice = slice(i - k_window + 1, i + 1) + if not finite[window_slice].all(): + continue + highest = float(np.max(high_f[window_slice])) + lowest = float(np.min(low_f[window_slice])) + if highest == lowest: + k[i] = 50.0 + else: + k[i] = (close_f[i] - lowest) / (highest - lowest) * 100.0 + d = moving_average_values(k, window=d_window, method="sma") + return {"k": k, "d": d} + + +def _value_area_mask(total: np.ndarray, value_area: float) -> tuple[np.ndarray, int, float, float]: + mask = np.zeros(len(total), dtype=bool) + if len(total) == 0 or float(np.sum(total)) <= 0: + return mask, -1, math.nan, math.nan + poc = int(np.argmax(total)) + target = float(np.sum(total)) * value_area + lo = hi = poc + acc = float(total[poc]) + mask[poc] = True + while acc < target and (lo > 0 or hi < len(total) - 1): + left = float(total[lo - 1]) if lo > 0 else -1.0 + right = float(total[hi + 1]) if hi < len(total) - 1 else -1.0 + if right > left: + hi += 1 + acc += float(total[hi]) + mask[hi] = True + else: + lo -= 1 + acc += float(total[lo]) + mask[lo] = True + return mask, poc, acc, target + + +def _volume_profile_arrays( + x: Any, + open_: Any, + high: Any, + low: Any, + close: Any, + volume: Any, + *, + start: Any = None, + end: Any = None, + anchor: Any = None, + rows: int = 100, + row_size: Optional[float] = None, + value_area: float = 0.70, +) -> dict[str, Any]: + arrays = [np.asarray(v) for v in (x, open_, high, low, close, volume)] + n = len(arrays[0]) + if any(len(a) != n for a in arrays): + raise ValueError("volume profile x/open/high/low/close/volume must have equal length") + if rows <= 0: + raise ValueError("rows must be positive") + if row_size is not None and row_size <= 0: + raise ValueError("row_size must be positive") + if not math.isfinite(value_area) or not 0 < value_area <= 1: + raise ValueError("value_area must be in (0, 1]") + if n == 0: + empty = np.asarray([], dtype=np.float64) + return { + "price_low": empty, + "price_high": empty, + "price_mid": empty, + "total": empty, + "up": empty, + "down": empty, + "delta": empty, + "value_area": np.asarray([], dtype=bool), + "poc_index": -1, + "value_area_low": math.nan, + "value_area_high": math.nan, + "max_total": 0.0, + "start_index": 0, + "end_index": 0, + "rows": 0, + } + + x_arr = arrays[0] + x_axis = _axis_values(x_arr) + order = None if np.all(np.diff(x_axis) >= 0) else np.argsort(x_axis, kind="stable") + if order is not None: + arrays = [a[order] for a in arrays] + x_arr = arrays[0] + x_axis = x_axis[order] + open_f, high_f, low_f, close_f, volume_f = [np.asarray(a, dtype=np.float64) for a in arrays[1:]] + if np.any(volume_f < 0): + raise ValueError("volume profile volume must be non-negative") + + if anchor is not None: + i0 = _anchor_slice_index(x_arr, anchor, n, default=0, side="left") + i1 = n + else: + i0 = _anchor_slice_index(x_arr, start, n, default=0, side="left") + i1 = _anchor_slice_index(x_arr, end, n, default=n, side="right") + if i1 < i0: + i0, i1 = i1, i0 + + finite_window = ( + np.isfinite(open_f[i0:i1]) + & np.isfinite(high_f[i0:i1]) + & np.isfinite(low_f[i0:i1]) + & np.isfinite(close_f[i0:i1]) + & np.isfinite(volume_f[i0:i1]) + ) + if i1 <= i0 or not finite_window.any(): + low_edge = high_edge = 0.0 + else: + lows = np.minimum(low_f[i0:i1][finite_window], high_f[i0:i1][finite_window]) + highs = np.maximum(low_f[i0:i1][finite_window], high_f[i0:i1][finite_window]) + low_edge = float(np.min(lows)) + high_edge = float(np.max(highs)) + if not math.isfinite(low_edge) or not math.isfinite(high_edge): + low_edge, high_edge = 0.0, 1.0 + if low_edge == high_edge: + pad = abs(low_edge) * 0.005 or 0.5 + low_edge -= pad + high_edge += pad + + if row_size is not None: + row_count = max(1, int(math.ceil((high_edge - low_edge) / row_size))) + edges = low_edge + np.arange(row_count + 1, dtype=np.float64) * row_size + edges[-1] = max(edges[-1], high_edge) + else: + row_count = int(rows) + edges = np.linspace(low_edge, high_edge, row_count + 1, dtype=np.float64) + + total = np.zeros(row_count, dtype=np.float64) + up = np.zeros(row_count, dtype=np.float64) + down = np.zeros(row_count, dtype=np.float64) + for o, h, lo, c, vol in zip( # noqa: B905 - equal-length slices + open_f[i0:i1], high_f[i0:i1], low_f[i0:i1], close_f[i0:i1], volume_f[i0:i1] + ): + if not all(math.isfinite(v) for v in (o, h, lo, c, vol)) or vol <= 0: + continue + bar_low = min(lo, h) + bar_high = max(lo, h) + if bar_low == bar_high: + idx = int(np.searchsorted(edges, bar_low, side="right") - 1) + idx = min(max(idx, 0), row_count - 1) + share = vol + total[idx] += share + if c > o: + up[idx] += share + else: + down[idx] += share + continue + first = int(np.searchsorted(edges, bar_low, side="right") - 1) + last = int(np.searchsorted(edges, bar_high, side="left")) + first = min(max(first, 0), row_count - 1) + last = min(max(last, 0), row_count - 1) + span = bar_high - bar_low + for idx in range(first, last + 1): + overlap = max(0.0, min(bar_high, edges[idx + 1]) - max(bar_low, edges[idx])) + if overlap <= 0: + continue + share = vol * overlap / span + total[idx] += share + if c > o: + up[idx] += share + else: + down[idx] += share + + mask, poc, _, _ = _value_area_mask(total, value_area) + price_low = edges[:-1] + price_high = edges[1:] + price_mid = (price_low + price_high) / 2.0 + va_prices = np.flatnonzero(mask) + value_area_low = float(price_low[va_prices[0]]) if len(va_prices) else math.nan + value_area_high = float(price_high[va_prices[-1]]) if len(va_prices) else math.nan + return { + "price_low": price_low, + "price_high": price_high, + "price_mid": price_mid, + "total": total, + "up": up, + "down": down, + "delta": up - down, + "value_area": mask, + "poc_index": poc, + "value_area_low": value_area_low, + "value_area_high": value_area_high, + "max_total": float(np.max(total)) if len(total) else 0.0, + "start_index": int(i0), + "end_index": int(i1), + "rows": int(row_count), + } + + +def _bars_pattern_arrays( + x: Any, + open_: Any, + high: Any, + low: Any, + close: Any, + *, + start: Any, + end: Any, + destination: Any, + mirrored: bool = False, + flipped: bool = False, + normalize: bool = False, + max_bars: Optional[int] = 240, +) -> dict[str, Any]: + arrays = [np.asarray(v) for v in (x, open_, high, low, close)] + n = len(arrays[0]) + if any(len(a) != n for a in arrays): + raise ValueError("bars pattern x/open/high/low/close must have equal length") + if max_bars is not None and max_bars <= 0: + raise ValueError("max_bars must be positive") + if n == 0: + empty = np.asarray([], dtype=np.float64) + return { + "x": empty, + "open": empty, + "high": empty, + "low": empty, + "close": empty, + "source_start_index": 0, + "source_end_index": 0, + "rows": 0, + } + + x_arr = arrays[0] + x_axis = _axis_values(x_arr) + order = None if np.all(np.diff(x_axis) >= 0) else np.argsort(x_axis, kind="stable") + if order is not None: + arrays = [a[order] for a in arrays] + x_arr = arrays[0] + x_axis = x_axis[order] + open_f, high_f, low_f, close_f = [np.asarray(a, dtype=np.float64) for a in arrays[1:]] + i0 = _anchor_slice_index(x_arr, start, n, default=0, side="left") + i1 = _anchor_slice_index(x_arr, end, n, default=n, side="right") + if i1 < i0: + i0, i1 = i1, i0 + finite = ( + np.isfinite(x_axis[i0:i1]) + & np.isfinite(open_f[i0:i1]) + & np.isfinite(high_f[i0:i1]) + & np.isfinite(low_f[i0:i1]) + & np.isfinite(close_f[i0:i1]) + ) + rel_idx = np.flatnonzero(finite) + if len(rel_idx) == 0: + empty = np.asarray([], dtype=np.float64) + return { + "x": empty, + "open": empty, + "high": empty, + "low": empty, + "close": empty, + "source_start_index": int(i0), + "source_end_index": int(i1), + "rows": 0, + } + idx = rel_idx + i0 + if max_bars is not None and len(idx) > max_bars: + keep = np.unique(np.linspace(0, len(idx) - 1, int(max_bars)).round().astype(int)) + idx = idx[keep] + if mirrored: + idx = idx[::-1] + + raw_x = x_axis[idx] + raw_o = open_f[idx] + raw_h = high_f[idx] + raw_l = low_f[idx] + raw_c = close_f[idx] + + dest = _anchor(destination) + dest_x = _anchor_x_value(dest, raw_x) + if dest_x is None: + step = float(np.nanmedian(np.diff(x_axis))) if n > 1 else 1.0 + if not math.isfinite(step) or step == 0: + step = 1.0 + dest_x = float(x_axis[min(max(i1 - 1, 0), n - 1)] + step) + dest_y_raw = dest.get("y") + dest_y = float(dest_y_raw) if dest_y_raw is not None else float(raw_o[0]) + + if len(raw_x) > 1: + source_offsets = np.abs(np.diff(raw_x if not mirrored else raw_x[::-1])) + if ( + len(source_offsets) + and np.all(np.isfinite(source_offsets)) + and np.any(source_offsets > 0) + ): + offsets = np.concatenate(([0.0], np.cumsum(source_offsets))) + else: + offsets = np.arange(len(raw_x), dtype=np.float64) + else: + offsets = np.asarray([0.0], dtype=np.float64) + out_x = float(dest_x) + offsets + + ref = float(raw_o[0]) + + def transform(values: np.ndarray) -> np.ndarray: + vals = np.asarray(values, dtype=np.float64) + out = dest_y * (vals / ref) if normalize and ref != 0 else dest_y + (vals - ref) + if flipped: + out = dest_y - (out - dest_y) + return out + + t_o = transform(raw_o) + t_h = transform(raw_h) + t_l = transform(raw_l) + t_c = transform(raw_c) + out_high = np.maximum.reduce([t_o, t_h, t_l, t_c]) # ty: ignore[no-matching-overload] + out_low = np.minimum.reduce([t_o, t_h, t_l, t_c]) # ty: ignore[no-matching-overload] + return { + "x": out_x, + "open": t_o, + "high": out_high, + "low": out_low, + "close": t_c, + "source_start_index": int(i0), + "source_end_index": int(i1), + "rows": int(len(out_x)), + } + + +def _positive_step(x_axis: np.ndarray) -> float: + if len(x_axis) < 2: + return 1.0 + diffs = np.diff(x_axis) + positive = diffs[np.isfinite(diffs) & (diffs > 0)] + if len(positive) == 0: + return 1.0 + step = float(np.median(positive)) + return step if math.isfinite(step) and step > 0 else 1.0 + + +def _ghost_feed_arrays( + x: Any, + open_: Any, + high: Any, + low: Any, + close: Any, + *, + anchor: Any, + direction: str = "up", + bars: int = 24, + avg_hl_ticks: float = 100.0, + variance_ticks: float = 100.0, + tick_size: Optional[float] = None, + seed: Optional[int] = None, +) -> dict[str, Any]: + arrays = [np.asarray(v) for v in (x, open_, high, low, close)] + n = len(arrays[0]) + if any(len(a) != n for a in arrays): + raise ValueError("ghost feed x/open/high/low/close must have equal length") + if direction not in {"up", "down", "flat"}: + raise ValueError("direction must be 'up', 'down', or 'flat'") + if bars <= 0: + raise ValueError("bars must be positive") + if avg_hl_ticks <= 0: + raise ValueError("avg_hl_ticks must be positive") + if variance_ticks < 0: + raise ValueError("variance_ticks must be non-negative") + if tick_size is not None and tick_size <= 0: + raise ValueError("tick_size must be positive") + if n == 0: + empty = np.asarray([], dtype=np.float64) + return {"x": empty, "open": empty, "high": empty, "low": empty, "close": empty, "rows": 0} + + x_arr = arrays[0] + x_axis = _axis_values(x_arr) + order = None if np.all(np.diff(x_axis) >= 0) else np.argsort(x_axis, kind="stable") + if order is not None: + arrays = [a[order] for a in arrays] + x_arr = arrays[0] + x_axis = x_axis[order] + open_f, high_f, low_f, close_f = [np.asarray(a, dtype=np.float64) for a in arrays[1:]] + finite = ( + np.isfinite(x_axis) + & np.isfinite(open_f) + & np.isfinite(high_f) + & np.isfinite(low_f) + & np.isfinite(close_f) + ) + if not finite.any(): + empty = np.asarray([], dtype=np.float64) + return {"x": empty, "open": empty, "high": empty, "low": empty, "close": empty, "rows": 0} + + anchor_spec = _anchor(anchor) + anchor_x = _anchor_x_value(anchor_spec, x_axis) + step = _positive_step(x_axis) + if anchor_x is None: + anchor_x = float(x_axis[np.flatnonzero(finite)[-1]] + step) + anchor_y_raw = anchor_spec.get("y") + anchor_y = ( + float(anchor_y_raw) + if anchor_y_raw is not None + else float(close_f[np.flatnonzero(finite)[-1]]) + ) + + window_idx = np.flatnonzero(finite)[-120:] + median_range = float(np.median(np.maximum(high_f[window_idx] - low_f[window_idx], 0.0))) + if not math.isfinite(median_range) or median_range <= 0: + median_range = abs(anchor_y) * 0.01 or 1.0 + inferred_tick = tick_size or median_range / avg_hl_ticks + avg_hl = max(avg_hl_ticks * inferred_tick, abs(anchor_y) * 1e-6) + variance = variance_ticks * inferred_tick + sign = 1.0 if direction == "up" else -1.0 if direction == "down" else 0.0 + drift = sign * max(variance * 0.18, avg_hl * 0.06) + rng = np.random.default_rng(0 if seed is None else seed) + + xs = anchor_x + np.arange(bars, dtype=np.float64) * step + out_o = np.empty(bars, dtype=np.float64) + out_h = np.empty(bars, dtype=np.float64) + out_l = np.empty(bars, dtype=np.float64) + out_c = np.empty(bars, dtype=np.float64) + prev_close = anchor_y + noise_std = variance * 0.35 + for i in range(bars): + o = prev_close + change = drift + (float(rng.normal(0.0, noise_std)) if noise_std > 0 else 0.0) + c = max(abs(anchor_y) * 1e-6, o + change) + span = max(abs(c - o) * 1.35, avg_hl * float(rng.lognormal(0.0, 0.18))) + upper = span * float(rng.uniform(0.18, 0.48)) + lower = span * float(rng.uniform(0.18, 0.48)) + out_o[i] = o + out_c[i] = c + out_h[i] = max(o, c) + upper + out_l[i] = min(o, c) - lower + prev_close = c + return { + "x": xs, + "open": out_o, + "high": out_h, + "low": out_l, + "close": out_c, + "rows": int(bars), + "tick_size": float(inferred_tick), + "avg_hl": float(avg_hl), + "variance": float(variance), + } + + +def volume_profile_values( + x: Any, + open: Any, # noqa: A002 - OHLC domain naming + high: Any, + low: Any, + close: Any, + volume: Any, + *, + start: Any = None, + end: Any = None, + anchor: Any = None, + rows: int = 100, + row_size: Optional[float] = None, + value_area: float = 0.70, +) -> dict[str, Any]: + """Compute fixed/anchored volume profile bins from OHLCV arrays. + + Bar volume is distributed across price rows by overlap with each bar's + high-low span. Up/down volume follows the common OHLC rule: `close > open` + is up volume; every other candle contributes down volume. + """ + return _volume_profile_arrays( + x, + open, + high, + low, + close, + volume, + start=start, + end=end, + anchor=anchor, + rows=rows, + row_size=row_size, + value_area=value_area, + ) + + +def _volume_bar_arrays(x: Any, open_: Any, close: Any, volume: Any) -> dict[str, Any]: + arrays = [np.asarray(v) for v in (x, open_, close, volume)] + n = len(arrays[0]) + if any(len(a) != n for a in arrays): + raise ValueError("volume bars x/open/close/volume must have equal length") + if n == 0: + empty = np.asarray([], dtype=np.float64) + return { + "x": empty, + "volume": empty, + "direction": np.asarray([], dtype=bool), + "max_volume": 0.0, + "rows": 0, + } + + x_axis = _axis_values(arrays[0]) + order = None if np.all(np.diff(x_axis) >= 0) else np.argsort(x_axis, kind="stable") + if order is not None: + arrays = [a[order] for a in arrays] + x_axis = x_axis[order] + open_f, close_f, volume_f = [np.asarray(a, dtype=np.float64) for a in arrays[1:]] + if np.any(volume_f < 0): + raise ValueError("volume bars volume must be non-negative") + finite = ( + np.isfinite(x_axis) & np.isfinite(open_f) & np.isfinite(close_f) & np.isfinite(volume_f) + ) + x_out = x_axis[finite] + volume_out = volume_f[finite] + direction = close_f[finite] >= open_f[finite] + return { + "x": x_out, + "volume": volume_out, + "direction": direction, + "max_volume": float(np.max(volume_out)) if len(volume_out) else 0.0, + "rows": int(len(volume_out)), + } + + +def _finite_range(*arrays: Any, default: tuple[float, float]) -> tuple[float, float]: + vals = [] + for arr in arrays: + a = np.asarray(arr, dtype=np.float64) + finite = a[np.isfinite(a)] + if len(finite): + vals.append(finite) + if not vals: + return default + all_vals = np.concatenate(vals) + lo = float(np.min(all_vals)) + hi = float(np.max(all_vals)) + if lo == hi: + pad = abs(lo) * 0.05 or 1.0 + return lo - pad, hi + pad + pad = (hi - lo) * 0.10 + return lo - pad, hi + pad + + +def _prepend_x_value(x_arr: np.ndarray) -> np.ndarray: + if len(x_arr) == 0: + return np.asarray([0.0], dtype=np.float64) + if np.issubdtype(x_arr.dtype, np.datetime64): + x_ms = x_arr.astype("datetime64[ms]") + if len(x_ms) > 1: + diffs = np.diff(x_ms.astype("int64")) + positive = diffs[np.isfinite(diffs) & (diffs > 0)] + step_ms = int(np.median(positive)) if len(positive) else 86_400_000 + else: + step_ms = 86_400_000 + return np.concatenate(([x_ms[0] - np.timedelta64(step_ms, "ms")], x_ms)) + x_float = np.asarray(x_arr, dtype=np.float64) + step = _positive_step(x_float) + return np.concatenate(([x_float[0] - step], x_float)) + + +def _float_vector(values: Any, *, name: str, allow_empty: bool = False) -> np.ndarray: + arr = np.asarray(values, dtype=np.float64) + if arr.ndim != 1: + raise ValueError(f"{name} must be one-dimensional") + if len(arr) == 0 and not allow_empty: + raise ValueError(f"{name} must not be empty") + if not np.all(np.isfinite(arr)): + raise ValueError(f"{name} must contain only finite values") + return arr + + +def equity_curve_values( + *, + returns: Any = None, + pnl: Any = None, + initial: float = 1.0, +) -> np.ndarray: + """Compute an equity curve from periodic returns or absolute PnL. + + The returned series includes the starting equity as the first value, so + `n` returns or PnL observations produce `n + 1` equity points. + """ + if (returns is None) == (pnl is None): + raise ValueError("provide exactly one of returns or pnl") + if not math.isfinite(initial): + raise ValueError("initial must be finite") + if returns is not None: + returns_f = _float_vector(returns, name="returns", allow_empty=True) + if np.any(returns_f < -1.0): + raise ValueError("returns must be greater than or equal to -100%") + equity = np.empty(len(returns_f) + 1, dtype=np.float64) + equity[0] = initial + equity[1:] = initial * np.cumprod(1.0 + returns_f) + return equity + + pnl_f = _float_vector(pnl, name="pnl", allow_empty=True) + equity = np.empty(len(pnl_f) + 1, dtype=np.float64) + equity[0] = initial + equity[1:] = initial + np.cumsum(pnl_f) + return equity + + +def returns_values(values: Any, *, method: str = "simple") -> np.ndarray: + """Compute period returns from a price, NAV, or equity series.""" + values_f = _float_vector(values, name="values", allow_empty=True) + if method not in {"simple", "log"}: + raise ValueError("method must be 'simple' or 'log'") + if len(values_f) < 2: + return np.asarray([], dtype=np.float64) + + prev = values_f[:-1] + curr = values_f[1:] + if method == "simple": + if np.any(prev == 0): + raise ValueError("simple returns require non-zero previous values") + return curr / prev - 1.0 + + if np.any(prev <= 0) or np.any(curr <= 0): + raise ValueError("log returns require positive values") + return np.log(curr / prev) + + +def drawdown_values(equity: Any) -> dict[str, Any]: + """Compute drawdown arrays and max-drawdown summary from an equity curve.""" + equity_f = _float_vector(equity, name="equity") + running_peak = np.maximum.accumulate(equity_f) + drawdown = equity_f - running_peak + with np.errstate(divide="ignore", invalid="ignore"): + drawdown_pct = drawdown / running_peak + drawdown_pct[~np.isfinite(drawdown_pct)] = np.nan + + trough_index = int(np.argmin(drawdown)) + peak_level = running_peak[trough_index] + peak_candidates = np.flatnonzero(equity_f[: trough_index + 1] == peak_level) + peak_index = int(peak_candidates[-1]) if len(peak_candidates) else 0 + recovered = np.flatnonzero(equity_f[trough_index + 1 :] >= peak_level) + recovery_index = int(trough_index + 1 + recovered[0]) if len(recovered) else None + return { + "equity": equity_f, + "running_peak": running_peak, + "drawdown": drawdown, + "drawdown_pct": drawdown_pct, + "max_drawdown": float(drawdown[trough_index]), + "max_drawdown_pct": float(drawdown_pct[trough_index]), + "peak_index": peak_index, + "trough_index": trough_index, + "recovery_index": recovery_index, + "drawdown_duration": int(trough_index - peak_index), + "recovery_duration": None if recovery_index is None else int(recovery_index - trough_index), + } + + +def _drawdown_range(values: np.ndarray) -> tuple[float, float]: + finite = values[np.isfinite(values)] + if len(finite) == 0: + return -1.0, 0.0 + lo = min(0.0, float(np.min(finite))) + hi = max(0.0, float(np.max(finite))) + if lo == hi: + return lo - 1.0, hi + 1.0 + pad = (hi - lo) * 0.06 + return lo - pad, hi + pad + + +def _performance_curve_arrays( + *, + x: Any = None, + equity: Any = None, + returns: Any = None, + pnl: Any = None, + initial: float = 1.0, + drawdown: str = "percent", +) -> dict[str, Any]: + if drawdown not in {"percent", "pct", "absolute"}: + raise ValueError("drawdown must be 'percent', 'pct', or 'absolute'") + if sum(v is not None for v in (equity, returns, pnl)) != 1: + raise ValueError("provide exactly one of equity, returns, or pnl") + if equity is None: + equity_f = equity_curve_values(returns=returns, pnl=pnl, initial=initial) + else: + equity_f = _float_vector(equity, name="equity") + initial = float(equity_f[0]) + + if x is None: + x_arr = np.arange(len(equity_f), dtype=np.float64) + else: + raw_x = np.asarray(x) + if len(raw_x) == len(equity_f) - 1: + x_arr = _prepend_x_value(raw_x) + elif len(raw_x) == len(equity_f): + x_arr = ( + raw_x.astype("datetime64[ms]") + if np.issubdtype(raw_x.dtype, np.datetime64) + else raw_x + ) + else: + raise ValueError("x must have length equal to equity length or returns/pnl length") + + if len(x_arr) != len(equity_f): + raise ValueError("x and equity must have equal length after alignment") + x_axis = _axis_values(x_arr) + order = ( + None + if len(x_axis) < 2 or np.all(np.diff(x_axis) >= 0) + else np.argsort(x_axis, kind="stable") + ) + if order is not None: + x_arr = x_arr[order] + equity_f = equity_f[order] + + dd = drawdown_values(equity_f) + drawdown_abs = np.asarray(dd["drawdown"], dtype=np.float64) + drawdown_pct = np.asarray(dd["drawdown_pct"], dtype=np.float64) * 100.0 + drawdown_y = drawdown_abs if drawdown == "absolute" else drawdown_pct + y_min, y_max = _drawdown_range(drawdown_y) + return { + "x": x_arr, + "equity": equity_f, + "running_peak": dd["running_peak"], + "drawdown": drawdown_abs, + "drawdown_pct": drawdown_pct, + "drawdown_y": drawdown_y, + "drawdown_mode": "absolute" if drawdown == "absolute" else "percent", + "initial": float(initial), + "rows": int(len(equity_f)), + "y_min": y_min, + "y_max": y_max, + "guides": [0.0], + "metrics": { + "max_drawdown": dd["max_drawdown"], + "max_drawdown_pct": dd["max_drawdown_pct"], + "peak_index": dd["peak_index"], + "trough_index": dd["trough_index"], + "recovery_index": dd["recovery_index"], + "drawdown_duration": dd["drawdown_duration"], + "recovery_duration": dd["recovery_duration"], + }, + } + + +def _confidence_level(confidence: float) -> float: + if not math.isfinite(confidence) or not 0.0 < confidence < 1.0: + raise ValueError("confidence must be in (0, 1)") + return float(confidence) + + +def var_cvar_values(returns: Any, *, confidence: float = 0.95) -> dict[str, Any]: + """Compute left-tail historical VaR and CVaR for a return series.""" + confidence_f = _confidence_level(confidence) + returns_f = _float_vector(returns, name="returns") + tail_probability = 1.0 - confidence_f + var = float(np.quantile(returns_f, tail_probability, method="linear")) + tail = returns_f[returns_f <= var] + cvar = float(np.mean(tail)) if len(tail) else var + return { + "confidence": confidence_f, + "tail_probability": tail_probability, + "var": var, + "cvar": cvar, + "var_loss": -var, + "cvar_loss": -cvar, + "tail_count": int(len(tail)), + } + + +def returns_distribution_values( + returns: Any, + *, + bins: Any = 50, + bin_range: Optional[tuple[float, float]] = None, + confidence: float = 0.95, +) -> dict[str, Any]: + """Compute a returns histogram plus VaR/CVaR marker positions.""" + returns_f = _float_vector(returns, name="returns") + if isinstance(bins, int) and bins <= 0: + raise ValueError("bins must be positive") + if bin_range is not None: + if len(bin_range) != 2: + raise ValueError("bin_range must be a two-value tuple") + lo, hi = float(bin_range[0]), float(bin_range[1]) + if not math.isfinite(lo) or not math.isfinite(hi) or lo >= hi: + raise ValueError("bin_range must be finite and increasing") + hist_range = (lo, hi) + else: + hist_range = None + + counts, bin_edges = np.histogram(returns_f, bins=bins, range=hist_range) + counts_f = counts.astype(np.float64) + total = float(np.sum(counts_f)) + probability = counts_f / total if total > 0 else counts_f + centers = (bin_edges[:-1] + bin_edges[1:]) / 2.0 + risk = var_cvar_values(returns_f, confidence=confidence) + confidence_pct = risk["confidence"] * 100.0 + return { + "counts": counts, + "probability": probability, + "bin_edges": bin_edges, + "bin_centers": centers, + "rows": int(len(counts)), + "var": risk["var"], + "cvar": risk["cvar"], + "risk": risk, + "markers": [ + {"role": "var", "label": f"VaR {confidence_pct:g}%", "x": risk["var"]}, + {"role": "cvar", "label": f"CVaR {confidence_pct:g}%", "x": risk["cvar"]}, + ], + } + + +def _histogram_y_range(values: np.ndarray) -> tuple[float, float]: + finite = values[np.isfinite(values)] + hi = float(np.max(finite)) if len(finite) else 1.0 + if hi <= 0: + hi = 1.0 + return 0.0, hi * 1.12 + + +@dataclass(frozen=True) +class Instrument: + """Instrument metadata needed by risk tools and price-axis formatting.""" + + tick_size: float = 0.01 + point_value: float = 1.0 + lot_size: float = 1.0 + qty_precision: int = 0 + currency: Optional[str] = None + leverage: float = 1.0 + multiplier: float = 1.0 + + def __post_init__(self) -> None: + for name in ("tick_size", "point_value", "lot_size", "leverage", "multiplier"): + if getattr(self, name) <= 0: + raise ValueError(f"{name} must be positive") + if self.qty_precision < 0: + raise ValueError("qty_precision must be non-negative") + + def to_spec(self) -> dict[str, Any]: + return { + "tick_size": self.tick_size, + "point_value": self.point_value, + "lot_size": self.lot_size, + "qty_precision": self.qty_precision, + "currency": self.currency, + "leverage": self.leverage, + "multiplier": self.multiplier, + } + + +def instrument( + *, + tick_size: float = 0.01, + point_value: float = 1.0, + lot_size: float = 1.0, + qty_precision: int = 0, + currency: Optional[str] = None, + leverage: float = 1.0, + multiplier: float = 1.0, +) -> Instrument: + return Instrument( + tick_size=tick_size, + point_value=point_value, + lot_size=lot_size, + qty_precision=qty_precision, + currency=currency, + leverage=leverage, + multiplier=multiplier, + ) + + +class FinanceLayer(Component): + role: str + kind: str + + def to_spec(self) -> dict[str, Any]: # pragma: no cover - interface marker + raise NotImplementedError + + +@dataclass(frozen=True) +class Layer(FinanceLayer): + role: str + kind: str + source: Optional[str] = None + id: Optional[str] = None + anchors: Mapping[str, Any] = field(default_factory=dict) + props: Mapping[str, Any] = field(default_factory=dict) + style: Mapping[str, Any] = field(default_factory=dict) + + def to_spec(self) -> dict[str, Any]: + return { + "role": self.role, + "kind": self.kind, + "id": self.id, + "source": self.source, + "anchors": _jsonable(self.anchors), + "props": _jsonable(self.props), + "style": _jsonable(self.style), + } + + +@dataclass(frozen=True) +class FinanceTools(Component): + active: str = "crosshair" + snap: str = "ohlc" + editable: bool = True + locked: bool = False + hidden: tuple[str, ...] = () + selected: Optional[str] = None + on_change: Optional[Callable[[dict[str, Any]], None]] = None + on_create: Optional[Callable[[dict[str, Any]], None]] = None + on_update: Optional[Callable[[dict[str, Any]], None]] = None + on_delete: Optional[Callable[[dict[str, Any]], None]] = None + on_select: Optional[Callable[[dict[str, Any]], None]] = None + on_hover: Optional[Callable[[dict[str, Any]], None]] = None + on_commit: Optional[Callable[[dict[str, Any]], None]] = None + + def to_spec(self) -> dict[str, Any]: + # Callbacks are Python-side hooks; the serializable spec only declares + # which events the client should emit. + event_names = [ + name + for name in ("change", "create", "update", "delete", "select", "hover", "commit") + if getattr(self, f"on_{name}") is not None + ] + return { + "active": self.active, + "snap": self.snap, + "editable": self.editable, + "locked": self.locked, + "hidden": list(self.hidden), + "selected": self.selected, + "events": event_names, + } + + +def finance_tools( + *, + active: str = "crosshair", + snap: str = "ohlc", + editable: bool = True, + locked: bool = False, + hidden: tuple[str, ...] = (), + selected: Optional[str] = None, + on_change: Optional[Callable[[dict[str, Any]], None]] = None, + on_create: Optional[Callable[[dict[str, Any]], None]] = None, + on_update: Optional[Callable[[dict[str, Any]], None]] = None, + on_delete: Optional[Callable[[dict[str, Any]], None]] = None, + on_select: Optional[Callable[[dict[str, Any]], None]] = None, + on_hover: Optional[Callable[[dict[str, Any]], None]] = None, + on_commit: Optional[Callable[[dict[str, Any]], None]] = None, +) -> FinanceTools: + return FinanceTools( + active=active, + snap=snap, + editable=editable, + locked=locked, + hidden=hidden, + selected=selected, + on_change=on_change, + on_create=on_create, + on_update=on_update, + on_delete=on_delete, + on_select=on_select, + on_hover=on_hover, + on_commit=on_commit, + ) + + +def _risk_amount(account_size: float, risk: float, risk_mode: str) -> tuple[float, str]: + if account_size <= 0: + raise ValueError("account_size must be positive") + if risk <= 0: + raise ValueError("risk must be positive") + if risk_mode == "auto": + risk_mode = "fraction" if risk <= 1 else "amount" + if risk_mode == "fraction": + return account_size * risk, risk_mode + if risk_mode == "amount": + return risk, risk_mode + raise ValueError("risk_mode must be 'auto', 'fraction', or 'amount'") + + +@dataclass(frozen=True) +class PositionDrawing(FinanceLayer): + side: str + source: str + entry: Any + stop: float + target: float + end: Any = None + account_size: float = 100_000.0 + risk: float = 0.01 + risk_mode: str = "auto" + instrument: Instrument = field(default_factory=Instrument) + id: Optional[str] = None + style: Mapping[str, Any] = field(default_factory=dict) + role: str = field(default="drawing", init=False) + kind: str = field(default="position", init=False) + + def __post_init__(self) -> None: + if self.side not in {"long", "short"}: + raise ValueError("side must be 'long' or 'short'") + entry_price = self.entry_price + if entry_price <= 0: + raise ValueError("entry price must be positive") + if self.stop <= 0 or self.target <= 0: + raise ValueError("stop and target must be positive") + if self.side == "long" and not (self.stop < entry_price < self.target): + raise ValueError("long position requires stop < entry < target") + if self.side == "short" and not (self.target < entry_price < self.stop): + raise ValueError("short position requires target < entry < stop") + _risk_amount(self.account_size, self.risk, self.risk_mode) + + @property + def entry_price(self) -> float: + anchor = _anchor(self.entry) + y = anchor.get("y") + if y is None: + raise ValueError("entry must include a price") + return float(y) + + def metrics(self) -> dict[str, Any]: + entry = self.entry_price + risk_amount, risk_mode = _risk_amount(self.account_size, self.risk, self.risk_mode) + inst = self.instrument + if self.side == "long": + stop_distance = entry - self.stop + target_distance = self.target - entry + else: + stop_distance = self.stop - entry + target_distance = entry - self.target + risk_per_lot = stop_distance * inst.point_value * inst.lot_size * inst.multiplier + if risk_per_lot <= 0: + raise ValueError("stop distance must be positive") + qty_risk = risk_amount / risk_per_lot + qty_leverage = ( + (self.account_size * inst.leverage / entry) * inst.point_value / inst.lot_size + ) + qty = min(qty_risk, qty_leverage) + qty_display = round(qty, inst.qty_precision) + pnl_unit = inst.point_value * inst.lot_size * inst.multiplier + profit_pnl = target_distance * qty * pnl_unit + loss_pnl = -stop_distance * qty * pnl_unit + tick = inst.tick_size + return { + "side": self.side, + "entry": entry, + "stop": self.stop, + "target": self.target, + "account_size": self.account_size, + "risk_mode": risk_mode, + "risk_amount": risk_amount, + "qty_risk": qty_risk, + "qty_leverage": qty_leverage, + "qty": qty, + "qty_display": qty_display, + "risk_reward": target_distance / stop_distance, + "target_offset": target_distance, + "target_percent": target_distance / entry * 100.0, + "target_ticks": target_distance / tick, + "stop_offset": stop_distance, + "stop_percent": stop_distance / entry * 100.0, + "stop_ticks": stop_distance / tick, + "profit_pnl": profit_pnl, + "loss_pnl": loss_pnl, + "target_account_balance": self.account_size + profit_pnl, + "stop_account_balance": self.account_size + loss_pnl, + } + + def to_spec(self) -> dict[str, Any]: + return { + "role": self.role, + "kind": self.kind, + "id": self.id, + "source": self.source, + "side": self.side, + "anchors": { + "entry": _anchor(self.entry), + "stop": _anchor(self.stop), + "target": _anchor(self.target), + "end": _anchor(self.end), + }, + "risk": { + "account_size": self.account_size, + "amount": self.risk, + "mode": self.risk_mode, + }, + "instrument": self.instrument.to_spec(), + "metrics": self.metrics(), + "style": _jsonable(self.style), + } + + +def long_position( + *, + source: str, + entry: Any, + stop: float, + target: float, + end: Any = None, + account_size: float = 100_000.0, + risk: float = 0.01, + risk_mode: str = "auto", + instrument: Optional[Instrument] = None, + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> PositionDrawing: + return PositionDrawing( + side="long", + source=source, + entry=entry, + stop=stop, + target=target, + end=end, + account_size=account_size, + risk=risk, + risk_mode=risk_mode, + instrument=instrument or Instrument(), + id=id, + style=style or {}, + ) + + +def short_position( + *, + source: str, + entry: Any, + stop: float, + target: float, + end: Any = None, + account_size: float = 100_000.0, + risk: float = 0.01, + risk_mode: str = "auto", + instrument: Optional[Instrument] = None, + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> PositionDrawing: + return PositionDrawing( + side="short", + source=source, + entry=entry, + stop=stop, + target=target, + end=end, + account_size=account_size, + risk=risk, + risk_mode=risk_mode, + instrument=instrument or Instrument(), + id=id, + style=style or {}, + ) + + +def _study( + kind: str, *, source: str, id: Optional[str], anchors=None, props=None, style=None +) -> Layer: + return Layer( + "study", + kind, + source=source, + id=id, + anchors=anchors or {}, + props=props or {}, + style=style or {}, + ) + + +def _drawing( + kind: str, + *, + source: Optional[str] = None, + id: Optional[str] = None, + anchors=None, + props=None, + style=None, +) -> Layer: + return Layer( + "drawing", + kind, + source=source, + id=id, + anchors=anchors or {}, + props=props or {}, + style=style or {}, + ) + + +def volume_bars( + *, + source: str, + pane: str = "volume", + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + return _study("volume_bars", source=source, id=id, props={"pane": pane}, style=style) + + +def equity_drawdown( + x: Any = None, + *, + equity: Any = None, + returns: Any = None, + pnl: Any = None, + initial: float = 1.0, + drawdown: str = "percent", + pane: str = "drawdown", + mode: str = "area", + id: Optional[str] = None, + name: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + """A performance curve with a synced drawdown pane. + + Provide exactly one of `equity`, `returns`, or `pnl`. The top pane renders + the equity/PnL curve as a normal line/area trace; the layer renders the + drawdown series in a lower synced pane. + """ + if mode not in {"area", "line"}: + raise ValueError("mode must be 'area' or 'line'") + series = _performance_curve_arrays( + x=x, + equity=equity, + returns=returns, + pnl=pnl, + initial=initial, + drawdown=drawdown, + ) + return _study( + "equity_drawdown", + source="", + id=id, + props={ + "pane": pane, + "mode": mode, + "name": name, + "drawdown_mode": series["drawdown_mode"], + "series": series, + }, + style=style, + ) + + +def returns_distribution( + returns: Any, + *, + bins: Any = 50, + bin_range: Optional[tuple[float, float]] = None, + confidence: float = 0.95, + y: str = "probability", + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + """A returns histogram with VaR/CVaR marker lines.""" + if y not in {"probability", "count"}: + raise ValueError("y must be 'probability' or 'count'") + dist = returns_distribution_values( + returns, + bins=bins, + bin_range=bin_range, + confidence=confidence, + ) + y_values = dist["counts"].astype(np.float64) if y == "count" else dist["probability"] + y_min, y_max = _histogram_y_range(np.asarray(y_values, dtype=np.float64)) + series = { + "bin_edges": dist["bin_edges"], + "bin_centers": dist["bin_centers"], + "counts": dist["counts"], + "probability": dist["probability"], + "y": y_values, + "y_mode": y, + "rows": dist["rows"], + "x_min": float(dist["bin_edges"][0]), + "x_max": float(dist["bin_edges"][-1]), + "y_min": y_min, + "y_max": y_max, + "markers": dist["markers"], + "risk": dist["risk"], + } + return _study( + "returns_distribution", + source="", + id=id, + props={"series": series, "confidence": confidence, "y": y}, + style=style, + ) + + +def moving_average( + *, + source: str, + value: str = "close", + window: int = 20, + method: str = "sma", + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + if window <= 0: + raise ValueError("window must be positive") + if method not in {"sma", "ema"}: + raise ValueError("method must be 'sma' or 'ema'") + return _study( + "moving_average", + source=source, + id=id, + props={"value": value, "window": window, "method": method}, + style=style, + ) + + +def bollinger_bands( + *, + source: str, + value: str = "close", + window: int = 20, + deviations: float = 2.0, + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + if window <= 0: + raise ValueError("window must be positive") + if not math.isfinite(deviations) or deviations <= 0: + raise ValueError("deviations must be positive") + return _study( + "bollinger_bands", + source=source, + id=id, + props={"value": value, "window": window, "deviations": deviations}, + style=style, + ) + + +def vwap( + *, + source: str, + price: str = "hlc3", + bands: Optional[tuple[float, ...]] = None, + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + return _study( + "vwap", + source=source, + id=id, + props={"price": price, "bands": list(bands) if bands else []}, + style=style, + ) + + +def rsi( + *, + source: str, + value: str = "close", + window: int = 14, + pane: str = "rsi", + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + if window <= 0: + raise ValueError("window must be positive") + return _study( + "rsi", + source=source, + id=id, + props={"value": value, "window": window, "pane": pane}, + style=style, + ) + + +def macd( + *, + source: str, + value: str = "close", + fast: int = 12, + slow: int = 26, + signal: int = 9, + pane: str = "macd", + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + if fast <= 0 or slow <= 0 or signal <= 0: + raise ValueError("fast, slow, and signal must be positive") + if fast >= slow: + raise ValueError("fast must be less than slow") + return _study( + "macd", + source=source, + id=id, + props={"value": value, "fast": fast, "slow": slow, "signal": signal, "pane": pane}, + style=style, + ) + + +def stochastic( + *, + source: str, + k_window: int = 14, + d_window: int = 3, + pane: str = "stochastic", + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + if k_window <= 0 or d_window <= 0: + raise ValueError("k_window and d_window must be positive") + return _study( + "stochastic", + source=source, + id=id, + props={"k_window": k_window, "d_window": d_window, "pane": pane}, + style=style, + ) + + +def anchored_vwap( + *, + source: str, + anchor: Any, + price: str = "hlc3", + bands: Optional[tuple[float, ...]] = None, + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + return _study( + "anchored_vwap", + source=source, + id=id, + anchors={"anchor": _anchor(anchor)}, + props={"price": price, "bands": list(bands) if bands else []}, + style=style, + ) + + +def fixed_range_volume_profile( + *, + source: str, + start: Any, + end: Any, + rows: int = 100, + row_size: Optional[float] = None, + volume: str = "total", + value_area: float = 0.70, + extend_right: bool = False, + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + if rows <= 0: + raise ValueError("rows must be positive") + if not math.isfinite(value_area) or not 0 < value_area <= 1: + raise ValueError("value_area must be in (0, 1]") + return _study( + "fixed_range_volume_profile", + source=source, + id=id, + anchors={"start": _anchor(start), "end": _anchor(end)}, + props={ + "rows": rows, + "row_size": row_size, + "volume": volume, + "value_area": value_area, + "extend_right": extend_right, + }, + style=style, + ) + + +def anchored_volume_profile( + *, + source: str, + anchor: Any, + rows: int = 100, + row_size: Optional[float] = None, + volume: str = "total", + value_area: float = 0.70, + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + if rows <= 0: + raise ValueError("rows must be positive") + if not math.isfinite(value_area) or not 0 < value_area <= 1: + raise ValueError("value_area must be in (0, 1]") + return _study( + "anchored_volume_profile", + source=source, + id=id, + anchors={"anchor": _anchor(anchor)}, + props={"rows": rows, "row_size": row_size, "volume": volume, "value_area": value_area}, + style=style, + ) + + +def position_forecast( + *, + source: str, + start: Any, + target: Any, + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + return _drawing( + "position_forecast", + source=source, + id=id, + anchors={"start": _anchor(start), "target": _anchor(target)}, + style=style, + ) + + +def bars_pattern( + *, + source: str, + start: Any, + end: Any, + destination: Any, + mode: str = "candlestick", + mirrored: bool = False, + flipped: bool = False, + normalize: bool = False, + max_bars: Optional[int] = 240, + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + if max_bars is not None and max_bars <= 0: + raise ValueError("max_bars must be positive") + return _drawing( + "bars_pattern", + source=source, + id=id, + anchors={ + "start": _bar_anchor(start), + "end": _bar_anchor(end), + "destination": _anchor(destination), + }, + props={ + "mode": mode, + "mirrored": mirrored, + "flipped": flipped, + "normalize": normalize, + "max_bars": max_bars, + }, + style=style, + ) + + +def ghost_feed( + *, + source: str, + anchor: Any, + direction: str = "up", + bars: int = 24, + avg_hl_ticks: float = 100.0, + variance_ticks: float = 100.0, + tick_size: Optional[float] = None, + seed: Optional[int] = None, + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + if bars <= 0: + raise ValueError("bars must be positive") + if direction not in {"up", "down", "flat"}: + raise ValueError("direction must be 'up', 'down', or 'flat'") + if avg_hl_ticks <= 0: + raise ValueError("avg_hl_ticks must be positive") + if variance_ticks < 0: + raise ValueError("variance_ticks must be non-negative") + if tick_size is not None and tick_size <= 0: + raise ValueError("tick_size must be positive") + return _drawing( + "ghost_feed", + source=source, + id=id, + anchors={"anchor": _anchor(anchor)}, + props={ + "direction": direction, + "bars": bars, + "avg_hl_ticks": avg_hl_ticks, + "variance_ticks": variance_ticks, + "tick_size": tick_size, + "seed": seed, + }, + style=style, + ) + + +def sector( + *, + source: str, + origin: Any, + horizon: Any, + target: Any, + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + return _drawing( + "sector", + source=source, + id=id, + anchors={"origin": _anchor(origin), "horizon": _anchor(horizon), "target": _anchor(target)}, + style=style, + ) + + +def price_range(*, source: str, start: Any, end: Any, id: Optional[str] = None) -> Layer: + return _drawing( + "price_range", source=source, id=id, anchors={"start": _anchor(start), "end": _anchor(end)} + ) + + +def date_range(*, source: str, start: Any, end: Any, id: Optional[str] = None) -> Layer: + return _drawing( + "date_range", source=source, id=id, anchors={"start": _anchor(start), "end": _anchor(end)} + ) + + +def date_price_range(*, source: str, start: Any, end: Any, id: Optional[str] = None) -> Layer: + return _drawing( + "date_price_range", + source=source, + id=id, + anchors={"start": _anchor(start), "end": _anchor(end)}, + ) + + +def abcd_pattern( + *, + points: list[Any], + source: Optional[str] = None, + validate: Optional[str] = None, + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + if len(points) != 4: + raise ValueError("ABCD pattern requires exactly four points") + return _drawing( + "abcd_pattern", + source=source, + id=id, + anchors={label: _anchor(point) for label, point in zip("ABCD", points, strict=True)}, + props={"validate": validate}, + style=style, + ) + + +def xabcd_pattern( + *, + points: list[Any], + source: Optional[str] = None, + validate: Optional[str] = None, + id: Optional[str] = None, + style: Optional[Mapping[str, Any]] = None, +) -> Layer: + if len(points) != 5: + raise ValueError("XABCD pattern requires exactly five points") + return _drawing( + "xabcd_pattern", + source=source, + id=id, + anchors={label: _anchor(point) for label, point in zip("XABCD", points, strict=True)}, + props={"validate": validate}, + style=style, + ) + + +def _source_key(mark: Mark) -> Optional[str]: + return mark.id or mark.name + + +def _resolve_mark_value(mark: Mark, chart_data: Any, value: Any) -> Any: + data = mark.data if mark.data is not None else chart_data + return _resolve(data, value) + + +def _ohlcv_sources(children: tuple[Component, ...], chart_data: Any) -> dict[str, dict[str, Any]]: + sources: dict[str, dict[str, Any]] = {} + for child in children: + if not isinstance(child, Mark) or child.kind not in {"candlestick", "ohlc"}: + continue + key = _source_key(child) + if not key: + continue + props = child.props + src = { + "x": _resolve_mark_value(child, chart_data, child.x), + "open": _resolve_mark_value(child, chart_data, props["open"]), + "high": _resolve_mark_value(child, chart_data, props["high"]), + "low": _resolve_mark_value(child, chart_data, props["low"]), + "close": _resolve_mark_value(child, chart_data, props["close"]), + "volume": None + if props.get("volume") is None + else _resolve_mark_value(child, chart_data, props["volume"]), + } + sources[key] = src + return sources + + +class FinanceChart(Component): + """A composed chart plus finance layers/tools. + + `figure()` returns the ordinary xy Figure for marks and axes. + `finance_spec()` returns the non-rendered finance overlay intent. When the + client layer registry lands, `build_payload()` already has a place to carry + these layers beside the normal mark payload. + """ + + def __init__(self, children: tuple[Component, ...], **props: Any) -> None: + self.children = children + self._tools = [c for c in children if isinstance(c, FinanceTools)] + self.layers = [c for c in children if isinstance(c, FinanceLayer)] + base_children = [c for c in children if isinstance(c, (Mark, Axis, Legend))] + unknown = [ + c + for c in children + if not isinstance(c, (Mark, Axis, Legend, FinanceLayer, FinanceTools)) + ] + if unknown: + raise TypeError( + "finance_chart() children must be marks/axes/legend/finance layers/tools, " + f"got {[type(c).__name__ for c in unknown]}" + ) + if len(self._tools) > 1: + raise ValueError("finance_chart() accepts at most one finance_tools() child") + self._chart = Chart("finance_chart", tuple(base_children), **props) + self._base_children = tuple(base_children) + self._figure = None + self._widget: Any = None + + def figure(self): + if self._figure is not None: + return self._figure + fig = self._chart.figure() + self._apply_computed_studies(fig) + self._figure = fig + return fig + + def _apply_computed_studies(self, fig) -> None: + sources = _ohlcv_sources(self._base_children, self._chart.data) + for layer in self.layers: + if not isinstance(layer, Layer): + continue + if layer.kind == "equity_drawdown": + series = layer.props.get("series") + if not isinstance(series, Mapping): + continue + name = layer.props.get("name") or layer.id or "Equity" + color = str(layer.style.get("color") or "#2563eb") + width = float(layer.style.get("width", 1.6)) + opacity = float(layer.style.get("opacity", 0.96)) + mode = str(layer.props.get("mode", "area")) + if mode == "line": + fig.line( + series["x"], + series["equity"], + name=str(name), + color=color, + width=width, + opacity=opacity, + ) + else: + fill_color = str(layer.style.get("fill_color") or color) + fig.area( + series["x"], + series["equity"], + name=str(name), + base=float(series.get("initial", series["equity"][0])), + color=fill_color, + opacity=float(layer.style.get("fill_opacity", 0.16)), + line_color=color, + line_width=width, + line_opacity=opacity, + ) + continue + source = sources.get(layer.source or "") + if not source: + continue + if layer.kind == "moving_average": + value = str(layer.props.get("value", "close")) + window = int(layer.props.get("window", 20)) + method = str(layer.props.get("method", "sma")) + values = _price_source( + value, + np.asarray(source["open"], dtype=np.float64), + np.asarray(source["high"], dtype=np.float64), + np.asarray(source["low"], dtype=np.float64), + np.asarray(source["close"], dtype=np.float64), + ) + avg = moving_average_values(values, window=window, method=method) + color = str(layer.style.get("color") or "#60a5fa") + width = float(layer.style.get("width", 1.4)) + opacity = float(layer.style.get("opacity", 0.92)) + name = layer.id or f"{method.upper()}{window}" + fig.line(source["x"], avg, name=name, color=color, width=width, opacity=opacity) + continue + if layer.kind == "bollinger_bands": + value = str(layer.props.get("value", "close")) + window = int(layer.props.get("window", 20)) + deviations = float(layer.props.get("deviations", 2.0)) + values = _price_source( + value, + np.asarray(source["open"], dtype=np.float64), + np.asarray(source["high"], dtype=np.float64), + np.asarray(source["low"], dtype=np.float64), + np.asarray(source["close"], dtype=np.float64), + ) + bands = bollinger_bands_values(values, window=window, deviations=deviations) + color = str(layer.style.get("color") or "#a78bfa") + band_color = str(layer.style.get("band_color") or color) + width = float(layer.style.get("width", 1.2)) + band_width = float(layer.style.get("band_width", max(0.8, width * 0.85))) + opacity = float(layer.style.get("opacity", 0.88)) + band_opacity = float(layer.style.get("band_opacity", min(opacity, 0.62))) + name = layer.id or f"BB{window}" + fig.line( + source["x"], + bands["middle"], + name=f"{name} mid", + color=color, + width=width, + opacity=opacity, + ) + fig.line( + source["x"], + bands["upper"], + name=f"{name} upper", + color=band_color, + width=band_width, + opacity=band_opacity, + ) + fig.line( + source["x"], + bands["lower"], + name=f"{name} lower", + color=band_color, + width=band_width, + opacity=band_opacity, + ) + continue + if layer.kind in {"anchored_vwap", "vwap"}: + if source["volume"] is None: + continue + price = str(layer.props.get("price", "hlc3")) + anchor = ( + layer.anchors.get("anchor", {"bar": 0}) + if layer.kind == "anchored_vwap" + else {"bar": 0} + ) + xs, vwap, std = _anchored_vwap_arrays( + source["x"], + source["open"], + source["high"], + source["low"], + source["close"], + source["volume"], + anchor=anchor, + price=price, + ) + if len(xs) == 0: + continue + color = str( + layer.style.get("color") + or ("#f59e0b" if layer.kind == "anchored_vwap" else "#22c55e") + ) + width = float(layer.style.get("width", 1.4)) + opacity = float(layer.style.get("opacity", 0.95)) + name = layer.id or ("AVWAP" if layer.kind == "anchored_vwap" else "VWAP") + fig.line(xs, vwap, name=name, color=color, width=width, opacity=opacity) + band_color = str(layer.style.get("band_color") or color) + band_width = float(layer.style.get("band_width", max(0.8, width * 0.75))) + band_opacity = float(layer.style.get("band_opacity", min(opacity, 0.55))) + for band in layer.props.get("bands", ()): + b = float(band) + fig.line( + xs, + vwap + std * b, + name=f"{name} +{b:g} std", + color=band_color, + width=band_width, + opacity=band_opacity, + ) + fig.line( + xs, + vwap - std * b, + name=f"{name} -{b:g} std", + color=band_color, + width=band_width, + opacity=band_opacity, + ) + continue + + def finance_spec(self) -> dict[str, Any]: + tools = self._tools[-1] if self._tools else FinanceTools() + return {"tools": tools.to_spec(), "layers": self._materialized_layers()} + + def _materialized_layers(self) -> list[dict[str, Any]]: + sources = _ohlcv_sources(self._base_children, self._chart.data) + specs: list[dict[str, Any]] = [] + for layer in self.layers: + spec = layer.to_spec() + if isinstance(layer, Layer) and layer.kind == "volume_bars": + source = sources.get(layer.source or "") + if source and source["volume"] is not None: + bars = _volume_bar_arrays( + source["x"], + source["open"], + source["close"], + source["volume"], + ) + spec.setdefault("props", {})["bars"] = _jsonable(bars) + if isinstance(layer, Layer) and layer.kind in {"rsi", "macd", "stochastic"}: + source = sources.get(layer.source or "") + if source: + props = layer.props + x_arr = np.asarray(source["x"]) + close_f = np.asarray(source["close"], dtype=np.float64) + if layer.kind == "rsi": + value = str(props.get("value", "close")) + values = _price_source( + value, + np.asarray(source["open"], dtype=np.float64), + np.asarray(source["high"], dtype=np.float64), + np.asarray(source["low"], dtype=np.float64), + close_f, + ) + rsi_arr = rsi_values(values, window=int(props.get("window", 14))) + spec.setdefault("props", {})["series"] = _jsonable( + { + "x": x_arr, + "rsi": rsi_arr, + "rows": int(len(rsi_arr)), + "y_min": 0.0, + "y_max": 100.0, + "guides": [30.0, 70.0], + } + ) + elif layer.kind == "macd": + value = str(props.get("value", "close")) + values = _price_source( + value, + np.asarray(source["open"], dtype=np.float64), + np.asarray(source["high"], dtype=np.float64), + np.asarray(source["low"], dtype=np.float64), + close_f, + ) + series = macd_values( + values, + fast=int(props.get("fast", 12)), + slow=int(props.get("slow", 26)), + signal=int(props.get("signal", 9)), + ) + y_min, y_max = _finite_range( + series["macd"], + series["signal"], + series["histogram"], + default=(-1.0, 1.0), + ) + spec.setdefault("props", {})["series"] = _jsonable( + { + "x": x_arr, + "macd": series["macd"], + "signal": series["signal"], + "histogram": series["histogram"], + "rows": int(len(series["macd"])), + "y_min": y_min, + "y_max": y_max, + "guides": [0.0], + } + ) + else: + series = stochastic_values( + source["high"], + source["low"], + close_f, + k_window=int(props.get("k_window", 14)), + d_window=int(props.get("d_window", 3)), + ) + spec.setdefault("props", {})["series"] = _jsonable( + { + "x": x_arr, + "k": series["k"], + "d": series["d"], + "rows": int(len(series["k"])), + "y_min": 0.0, + "y_max": 100.0, + "guides": [20.0, 80.0], + } + ) + if isinstance(layer, Layer) and layer.kind in { + "fixed_range_volume_profile", + "anchored_volume_profile", + }: + source = sources.get(layer.source or "") + if source and source["volume"] is not None: + props = layer.props + kwargs: dict[str, Any] = { + "rows": int(props.get("rows", 100)), + "row_size": props.get("row_size"), + "value_area": float(props.get("value_area", 0.70)), + } + if layer.kind == "fixed_range_volume_profile": + kwargs["start"] = layer.anchors.get("start") + kwargs["end"] = layer.anchors.get("end") + else: + kwargs["anchor"] = layer.anchors.get("anchor") + profile = _volume_profile_arrays( + source["x"], + source["open"], + source["high"], + source["low"], + source["close"], + source["volume"], + **kwargs, + ) + spec.setdefault("props", {})["profile"] = _jsonable(profile) + if isinstance(layer, Layer) and layer.kind == "bars_pattern": + source = sources.get(layer.source or "") + if source: + props = layer.props + pattern = _bars_pattern_arrays( + source["x"], + source["open"], + source["high"], + source["low"], + source["close"], + start=layer.anchors.get("start"), + end=layer.anchors.get("end"), + destination=layer.anchors.get("destination"), + mirrored=bool(props.get("mirrored", False)), + flipped=bool(props.get("flipped", False)), + normalize=bool(props.get("normalize", False)), + max_bars=props.get("max_bars"), + ) + spec.setdefault("props", {})["pattern"] = _jsonable(pattern) + if isinstance(layer, Layer) and layer.kind == "ghost_feed": + source = sources.get(layer.source or "") + if source: + props = layer.props + feed = _ghost_feed_arrays( + source["x"], + source["open"], + source["high"], + source["low"], + source["close"], + anchor=layer.anchors.get("anchor"), + direction=str(props.get("direction", "up")), + bars=int(props.get("bars", 24)), + avg_hl_ticks=float(props.get("avg_hl_ticks", 100.0)), + variance_ticks=float(props.get("variance_ticks", 100.0)), + tick_size=props.get("tick_size"), + seed=props.get("seed"), + ) + spec.setdefault("props", {})["feed"] = _jsonable(feed) + specs.append(spec) + return specs + + def _apply_layer_axis_ranges(self, spec: dict[str, Any], layers: list[dict[str, Any]]) -> None: + for layer in layers: + if layer.get("kind") != "returns_distribution": + continue + series = layer.get("props", {}).get("series", {}) + if not series: + continue + spec["x_axis"]["range"] = [series["x_min"], series["x_max"]] + spec["x_axis"]["label"] = spec["x_axis"].get("label") or "Return" + spec["y_axis"]["range"] = [series["y_min"], series["y_max"]] + spec["y_axis"]["label"] = spec["y_axis"].get("label") or ( + "Probability" if series.get("y_mode") == "probability" else "Count" + ) + + def build_payload(self, px_width: int = 2048): + spec, blob = self.figure().build_payload(px_width=px_width) + finance_spec = self.finance_spec() + self._apply_layer_axis_ranges(spec, finance_spec["layers"]) + spec.update(finance_spec) + return spec, blob + + @property + def title(self) -> Optional[str]: + return self._chart.title + + def density_view( + self, trace_id: int, x0: float, x1: float, y0: float, y1: float, w: int, h: int + ): + return self.figure().density_view(trace_id, x0, x1, y0, y1, w, h) + + def pick(self, trace_id: int, index: int, drill_seq: Optional[int] = None): + return self.figure().pick(trace_id, index, drill_seq) + + def select_range( + self, x0: float, x1: float, y0: float, y1: float, trace_id: Optional[int] = None + ): + return self.figure().select_range(x0, x1, y0, y1, trace_id) + + def to_shipped_indices(self, trace_id: int, canonical): + return self.figure().to_shipped_indices(trace_id, canonical) + + def decimate_view(self, x0: float, x1: float, px_width: int): + return self.figure().decimate_view(x0, x1, px_width) + + def widget(self) -> Any: + if getattr(self, "_widget", None) is None: + from .widget import FigureWidget + + # FinanceChart duck-types the Figure payload surface (runtime-verified). + self._widget = FigureWidget(self) # ty: ignore[invalid-argument-type] + return self._widget + + def show(self) -> Any: + return self.widget() + + def _ipython_display_(self) -> None: + self._chart._ipython_display_() + + def to_html(self, path: Optional[str] = None) -> str: + return export.to_html(self, path) # ty: ignore[invalid-argument-type] + + def memory_report(self) -> dict: + return self.figure().memory_report() + + +def finance_chart(*children: Component, **props: Any) -> FinanceChart: + """Compose normal marks with finance studies, drawings, and tool state.""" + return FinanceChart(children, **props) + + +def performance_chart( + x: Any = None, + *, + equity: Any = None, + returns: Any = None, + pnl: Any = None, + initial: float = 1.0, + drawdown: str = "percent", + mode: str = "area", + title: Optional[str] = None, + width: "int | str" = 900, + height: "int | str" = 420, + style: Optional[Mapping[str, Any]] = None, +) -> FinanceChart: + """Create an equity/PnL performance chart with a synced drawdown pane.""" + return finance_chart( + equity_drawdown( + x=x, + equity=equity, + returns=returns, + pnl=pnl, + initial=initial, + drawdown=drawdown, + mode=mode, + id="performance", + style=style, + ), + x_axis(), + y_axis(label="Equity", side="right"), + title=title, + width=width, + height=height, + ) + + +def returns_distribution_chart( + returns: Any, + *, + bins: Any = 50, + bin_range: Optional[tuple[float, float]] = None, + confidence: float = 0.95, + y: str = "probability", + title: Optional[str] = None, + width: "int | str" = 900, + height: "int | str" = 420, + style: Optional[Mapping[str, Any]] = None, +) -> FinanceChart: + """Create a returns histogram with VaR/CVaR marker lines.""" + return finance_chart( + returns_distribution( + returns, + bins=bins, + bin_range=bin_range, + confidence=confidence, + y=y, + id="returns_distribution", + style=style, + ), + x_axis(label="Return"), + y_axis(label="Probability" if y == "probability" else "Count", side="right"), + title=title, + width=width, + height=height, + ) diff --git a/python/xy/marks.py b/python/xy/marks.py index 72b4252e..0e83e5d4 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -1348,6 +1348,135 @@ def area( raise +def candlestick( + self: "Figure", + x: ArrayLike, + open: ArrayLike, # noqa: A002 - OHLC domain naming + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + *, + name: Optional[str] = None, + up_color: str = "#26a69a", + down_color: str = "#ef5350", + width_frac: float = 0.7, + opacity: float = 1.0, + hollow: bool = False, + wick_color: Optional[str] = None, +) -> "Figure": + """Add an OHLC candlestick trace with low/high autorange.""" + return _add_ohlc( + self, + "candlestick", + x, + open, + high, + low, + close, + name=name, + up_color=up_color, + down_color=down_color, + width_frac=width_frac, + opacity=opacity, + hollow=hollow, + wick_color=wick_color, + ) + + +def ohlc( + self: "Figure", + x: ArrayLike, + open: ArrayLike, # noqa: A002 - OHLC domain naming + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + *, + name: Optional[str] = None, + up_color: str = "#26a69a", + down_color: str = "#ef5350", + width_frac: float = 0.7, + opacity: float = 1.0, +) -> "Figure": + """Add an OHLC bar trace.""" + return _add_ohlc( + self, + "ohlc", + x, + open, + high, + low, + close, + name=name, + up_color=up_color, + down_color=down_color, + width_frac=width_frac, + opacity=opacity, + hollow=False, + wick_color=None, + ) + + +def _add_ohlc( + self: "Figure", + kind: str, + x: ArrayLike, + open: ArrayLike, # noqa: A002 - OHLC domain naming + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + *, + name: Optional[str], + up_color: str, + down_color: str, + width_frac: float, + opacity: float, + hollow: bool, + wick_color: Optional[str], +) -> "Figure": + name = self._optional_text(name, f"{kind} name") + up_color = self._optional_css_color(up_color, f"{kind} up_color") or "#26a69a" + down_color = self._optional_css_color(down_color, f"{kind} down_color") or "#ef5350" + wick_color = self._optional_css_color(wick_color, f"{kind} wick_color") + width_frac = self._positive_scalar(width_frac, f"{kind} width_frac") + opacity = self._opacity(opacity, f"{kind} opacity") + hollow = _validate.bool_param(hollow, f"{kind} hollow") + checkpoint = self._checkpoint() + try: + cols = [self.store.ingest(v) for v in (x, open, high, low, close)] + lengths = [len(column) for column in cols] + if any(length != lengths[0] for length in lengths): + raise ValueError(f"{kind} x/open/high/low/close must have equal length, got {lengths}") + if self.coords != "polar" and not kernels.is_sorted(cols[0].values): + order = np.argsort(cols[0].values, kind="stable") + cols = [self.store.ingest(column.values[order]) for column in cols] + xc, oc, hc, lc, cc = cols + self.traces.append( + Trace( + id=len(self.traces), + kind=kind, + x=xc, + y=cc, + name=name, + style={ + "up_color": up_color, + "down_color": down_color, + "width_frac": width_frac, + "opacity": opacity, + "hollow": hollow, + "wick_color": wick_color, + }, + open_=oc, + high=hc, + low=lc, + close=cc, + ) + ) + return self + except Exception: + self._rollback(checkpoint) + raise + + def error_band( self: "Figure", x: ArrayLike, diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index 31ee6978..5413c945 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -284,6 +284,26 @@ benchmark tracks this as part of the core 2D payload budget. - **Transport**: data-less JSON spec + one binary blob; no JSON numbers (§29). - **Ticks / axes / time axis / autorange**: keyed on axis kind, not mark kind. +## Drawing and finance overlay boundary + +Trading-workstation features such as long/short position boxes, forecasts, +ghost feed, sectors, anchored VWAP, volume profiles, and chart patterns are not +new `candlestick` options. They are a separate layer system over chart marks: + +- `Mark`: owns plotted data columns (`candlestick`, `ohlc`, `line`, `volume`). +- `Study`: computes derived data from source marks (`anchored_vwap`, moving + averages, Bollinger bands, volume profile bins). +- `Drawing`: stores user/Python-authored anchors and derived geometry + (`long_position`, `position_forecast`, `bars_pattern`, `xabcd_pattern`). +- `ToolState`: active tool, selected drawing, snapping, visibility, locking, + object tree, templates, and Reflex event hooks. + +This preserves the chart-kind contract: new data marks still enter through +`Figure.` + `_emit_` + `MARK_KINDS[K]`. Drawings and studies should add a +parallel layer registry rather than branching the mark render loop or inflating +`candlestick()`. See [`docs/quant-finance-roadmap.md`](../../docs/quant-finance-roadmap.md) +for the detailed API and implementation plan. + ## Registry capabilities Beyond `build`/`draw`, `MARK_KINDS` entries carry capability flags/hooks so no diff --git a/spec/api/chart-roadmap.md b/spec/api/chart-roadmap.md index 042dd7de..e3012cff 100644 --- a/spec/api/chart-roadmap.md +++ b/spec/api/chart-roadmap.md @@ -242,6 +242,15 @@ underneath. | Geography and logistics | point maps, choropleth, density maps, lines/routes, projected scatter | | Product analytics | cohort heatmaps, retention curves, funnels, event timelines, linked cross-filters | +The finance target is broader than candlesticks. The detailed quant-finance +plan lives in [`docs/quant-finance-roadmap.md`](../../docs/quant-finance-roadmap.md) and +covers TradingView-class forecasting, long/short position risk boxes, bars +patterns, ghost feed, sectors, anchored VWAP, fixed/anchored volume profiles, +manual chart patterns, snapping, persistence, Reflex-controlled customization, +and multi-pane production requirements. The key API decision: these features +should be first-class composed overlay/study/drawing components, not kwargs on +`candlestick()`. + Breadth should arrive after the core primitives are solid: 1. rectangle marks for bar/histogram/waterfall/funnel, diff --git a/tests/test_api_parity.py b/tests/test_api_parity.py index 8195a4fe..4ce0586b 100644 --- a/tests/test_api_parity.py +++ b/tests/test_api_parity.py @@ -30,7 +30,16 @@ # `data`/`key` are resolved into arrays before or after the engine call, and # class/axis/animation hooks configure declarative trace metadata rather than # changing the shared mark geometry implementation. -COMPOSITION_ONLY = {"data", "class_name", "key", "animation", "x_axis", "y_axis"} +COMPOSITION_ONLY = { + "data", + "class_name", + "key", + "animation", + "x_axis", + "y_axis", + "id", + "volume", +} # factory name -> Figure method name (same-named today; the pairing is # explicit so a future rename must update the guard deliberately). @@ -40,6 +49,8 @@ ("sankey", "sankey"), ("line", "line"), ("area", "area"), + ("candlestick", "candlestick"), + ("ohlc", "ohlc"), ("histogram", "histogram"), ("hist", "hist"), ("bar", "bar"), @@ -66,6 +77,8 @@ "sankey": lambda: xy.sankey([("a", "b", 1.0)]), "line": lambda: xy.line(x=[1.0, 2.0], y=[3.0, 4.0]), "area": lambda: xy.area(x=[1.0, 2.0], y=[3.0, 4.0]), + "candlestick": lambda: xy.candlestick(x=[1.0], open=[2.0], high=[3.0], low=[1.0], close=[2.5]), + "ohlc": lambda: xy.ohlc(x=[1.0], open=[2.0], high=[3.0], low=[1.0], close=[2.5]), "histogram": lambda: xy.histogram(values=[1.0, 2.0, 3.0]), "bar": lambda: xy.bar(x=["a", "b"], y=[1.0, 2.0]), "column": lambda: xy.column(x=["a", "b"], y=[1.0, 2.0]), diff --git a/tests/test_finance.py b/tests/test_finance.py new file mode 100644 index 00000000..195d5d55 --- /dev/null +++ b/tests/test_finance.py @@ -0,0 +1,764 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import xy as fc +from xy.finance import FinanceChart, FinanceLayer, FinanceTools, Instrument, PositionDrawing + + +def _ohlc(): + x = np.arange(5.0) + open_ = np.array([100.0, 101.0, 102.0, 103.0, 104.0]) + high = open_ + 2 + low = open_ - 2 + close = open_ + 1 + return x, open_, high, low, close + + +def _ohlcv(): + x, open_, high, low, close = _ohlc() + volume = np.array([10.0, 20.0, 30.0, 40.0, 50.0]) + return x, open_, high, low, close, volume + + +def test_finance_factories_return_components(): + assert isinstance(fc.instrument(), Instrument) + assert isinstance(fc.finance_tools(), FinanceTools) + assert isinstance( + fc.long_position(source="price", entry=(1, 100.0), stop=95.0, target=115.0), PositionDrawing + ) + assert isinstance(fc.anchored_vwap(source="price", anchor=(1, 100.0)), FinanceLayer) + chart = fc.finance_chart(fc.candlestick(*_ohlc(), name="price")) + assert isinstance(chart, FinanceChart) + + +def test_anchored_vwap_values_from_anchor_bar(): + x = np.array([0.0, 1.0, 2.0, 3.0]) + close = np.array([10.0, 20.0, 30.0, 40.0]) + volume = np.array([1.0, 3.0, 6.0, 1.0]) + xs, vwap = fc.anchored_vwap_values( + x, + close, + close, + close, + close, + volume, + anchor={"bar": 1}, + price="close", + ) + np.testing.assert_array_equal(xs, [1.0, 2.0, 3.0]) + np.testing.assert_allclose(vwap, [20.0, 26.6666666667, 28.0]) + + +def test_ta_reference_values(): + values = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + sma = fc.moving_average_values(values, window=3, method="sma") + ema = fc.moving_average_values(values, window=3, method="ema") + np.testing.assert_allclose(sma, [np.nan, np.nan, 2.0, 3.0, 4.0], equal_nan=True) + np.testing.assert_allclose(ema, [1.0, 1.5, 2.25, 3.125, 4.0625]) + + bands = fc.bollinger_bands_values(values, window=3, deviations=2.0) + std = np.sqrt(2.0 / 3.0) + np.testing.assert_allclose(bands["middle"], [np.nan, np.nan, 2.0, 3.0, 4.0], equal_nan=True) + np.testing.assert_allclose( + bands["upper"], + [np.nan, np.nan, 2.0 + 2 * std, 3.0 + 2 * std, 4.0 + 2 * std], + equal_nan=True, + ) + np.testing.assert_allclose( + bands["lower"], + [np.nan, np.nan, 2.0 - 2 * std, 3.0 - 2 * std, 4.0 - 2 * std], + equal_nan=True, + ) + + +def test_oscillator_reference_values(): + values = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + rsi = fc.rsi_values(values, window=3) + np.testing.assert_allclose(rsi, [np.nan, np.nan, np.nan, 100.0, 100.0], equal_nan=True) + np.testing.assert_allclose( + fc.rsi_values(np.ones(5), window=3), + [np.nan, np.nan, np.nan, 50.0, 50.0], + equal_nan=True, + ) + + macd = fc.macd_values(values, fast=2, slow=3, signal=2) + expected_macd = fc.moving_average_values( + values, window=2, method="ema" + ) - fc.moving_average_values( + values, + window=3, + method="ema", + ) + expected_signal = fc.moving_average_values(expected_macd, window=2, method="ema") + np.testing.assert_allclose(macd["macd"], expected_macd) + np.testing.assert_allclose(macd["signal"], expected_signal) + np.testing.assert_allclose(macd["histogram"], expected_macd - expected_signal) + + high = np.array([2.0, 3.0, 4.0, 5.0, 6.0]) + low = np.array([0.0, 1.0, 2.0, 3.0, 4.0]) + close = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + stoch = fc.stochastic_values(high, low, close, k_window=3, d_window=2) + np.testing.assert_allclose(stoch["k"], [np.nan, np.nan, 75.0, 75.0, 75.0], equal_nan=True) + np.testing.assert_allclose(stoch["d"], [np.nan, np.nan, np.nan, 75.0, 75.0], equal_nan=True) + + +def test_vwap_values_from_start(): + x = np.array([0.0, 1.0, 2.0]) + close = np.array([10.0, 20.0, 30.0]) + volume = np.array([1.0, 3.0, 6.0]) + xs, vwap = fc.vwap_values(x, close, close, close, close, volume, price="close") + np.testing.assert_array_equal(xs, x) + np.testing.assert_allclose(vwap, [10.0, 17.5, 25.0]) + + +def test_volume_profile_values_distributes_volume_by_price_overlap(): + x = np.array([0.0, 1.0]) + open_ = np.array([0.0, 3.0]) + high = np.array([2.0, 4.0]) + low = np.array([0.0, 2.0]) + close = np.array([2.0, 2.5]) + volume = np.array([10.0, 20.0]) + profile = fc.volume_profile_values( + x, + open_, + high, + low, + close, + volume, + start={"bar": 0}, + end={"bar": 1}, + rows=4, + value_area=0.5, + ) + np.testing.assert_allclose(profile["price_low"], [0.0, 1.0, 2.0, 3.0]) + np.testing.assert_allclose(profile["price_high"], [1.0, 2.0, 3.0, 4.0]) + np.testing.assert_allclose(profile["total"], [5.0, 5.0, 10.0, 10.0]) + np.testing.assert_allclose(profile["up"], [5.0, 5.0, 0.0, 0.0]) + np.testing.assert_allclose(profile["down"], [0.0, 0.0, 10.0, 10.0]) + assert profile["poc_index"] == 2 + np.testing.assert_array_equal(profile["value_area"], [False, False, True, True]) + + +def test_finance_chart_computes_anchored_vwap_trace_from_ohlcv_source(): + x, open_, high, low, close, volume = _ohlcv() + chart = fc.finance_chart( + fc.candlestick( + x=x, + open=open_, + high=high, + low=low, + close=close, + volume=volume, + id="price", + name="candles", + ), + fc.anchored_vwap( + source="price", + anchor={"bar": 1}, + price="close", + bands=(1.0,), + id="avwap", + style={"color": "#ffaa00"}, + ), + ) + fig = chart.figure() + assert [trace.kind for trace in fig.traces] == ["candlestick", "line", "line", "line"] + assert fig.traces[1].name == "avwap" + assert fig.traces[1].style["color"] == "#ffaa00" + np.testing.assert_array_equal(fig.traces[1].x.values, x[1:]) + expected = np.cumsum(close[1:] * volume[1:]) / np.cumsum(volume[1:]) + np.testing.assert_allclose(fig.traces[1].y.values, expected) + spec, _ = chart.build_payload() + assert [trace["kind"] for trace in spec["traces"]] == ["candlestick", "line", "line", "line"] + assert spec["traces"][1]["name"] == "avwap" + + +def test_finance_chart_computes_ta_overlay_traces_from_ohlcv_source(): + x, open_, high, low, close, volume = _ohlcv() + chart = fc.finance_chart( + fc.candlestick( + x=x, + open=open_, + high=high, + low=low, + close=close, + volume=volume, + id="price", + ), + fc.moving_average(source="price", value="close", window=3, method="sma", id="sma3"), + fc.bollinger_bands( + source="price", + value="close", + window=3, + deviations=2.0, + id="bb3", + ), + fc.vwap(source="price", price="close", id="vwap"), + ) + fig = chart.figure() + assert [trace.kind for trace in fig.traces] == [ + "candlestick", + "line", + "line", + "line", + "line", + "line", + ] + assert [trace.name for trace in fig.traces[1:]] == [ + "sma3", + "bb3 mid", + "bb3 upper", + "bb3 lower", + "vwap", + ] + np.testing.assert_allclose( + fig.traces[1].y.values, [np.nan, np.nan, 102.0, 103.0, 104.0], equal_nan=True + ) + band_std = np.sqrt(2.0 / 3.0) + np.testing.assert_allclose( + fig.traces[2].y.values, [np.nan, np.nan, 102.0, 103.0, 104.0], equal_nan=True + ) + np.testing.assert_allclose( + fig.traces[3].y.values, + [np.nan, np.nan, 102.0 + 2 * band_std, 103.0 + 2 * band_std, 104.0 + 2 * band_std], + equal_nan=True, + ) + expected_vwap = np.cumsum(close * volume) / np.cumsum(volume) + np.testing.assert_allclose(fig.traces[5].y.values, expected_vwap) + spec, _ = chart.build_payload() + assert [trace["name"] for trace in spec["traces"][1:]] == [ + "sma3", + "bb3 mid", + "bb3 upper", + "bb3 lower", + "vwap", + ] + + +def test_finance_chart_materializes_volume_profile_layer_from_ohlcv_source(): + x, open_, high, low, close, volume = _ohlcv() + chart = fc.finance_chart( + fc.candlestick( + x=x, + open=open_, + high=high, + low=low, + close=close, + volume=volume, + id="price", + ), + fc.fixed_range_volume_profile( + source="price", + start={"bar": 0}, + end={"bar": 4}, + rows=6, + volume="up_down", + ), + ) + spec, _ = chart.build_payload() + profile = spec["layers"][0]["props"]["profile"] + assert profile["rows"] == 6 + assert profile["poc_index"] >= 0 + assert profile["max_total"] > 0 + assert len(profile["total"]) == 6 + assert len(profile["value_area"]) == 6 + + +def test_finance_chart_materializes_volume_bars_from_ohlcv_source(): + x, open_, high, low, close, volume = _ohlcv() + chart = fc.finance_chart( + fc.candlestick( + x=x, + open=open_, + high=high, + low=low, + close=close, + volume=volume, + id="price", + ), + fc.volume_bars(source="price", pane="volume", id="vol"), + ) + spec, _ = chart.build_payload() + layer = spec["layers"][0] + assert layer["kind"] == "volume_bars" + assert layer["props"]["pane"] == "volume" + bars = layer["props"]["bars"] + assert bars["rows"] == 5 + assert bars["max_volume"] == 50.0 + np.testing.assert_allclose(bars["x"], x) + np.testing.assert_allclose(bars["volume"], volume) + np.testing.assert_array_equal(bars["direction"], [True, True, True, True, True]) + + +def test_finance_chart_materializes_oscillator_layers_from_ohlcv_source(): + x, open_, high, low, close, volume = _ohlcv() + chart = fc.finance_chart( + fc.candlestick( + x=x, + open=open_, + high=high, + low=low, + close=close, + volume=volume, + id="price", + ), + fc.rsi(source="price", window=3, id="rsi3"), + fc.macd(source="price", fast=2, slow=3, signal=2, id="macd2"), + fc.stochastic(source="price", k_window=3, d_window=2, id="stoch3"), + ) + assert [trace.kind for trace in chart.figure().traces] == ["candlestick"] + spec, _ = chart.build_payload() + layers = {layer["kind"]: layer for layer in spec["layers"]} + assert set(layers) == {"rsi", "macd", "stochastic"} + + rsi_series = layers["rsi"]["props"]["series"] + assert rsi_series["rows"] == 5 + assert rsi_series["y_min"] == 0.0 + assert rsi_series["y_max"] == 100.0 + assert rsi_series["guides"] == [30.0, 70.0] + np.testing.assert_allclose(rsi_series["x"], x) + np.testing.assert_allclose( + rsi_series["rsi"], [np.nan, np.nan, np.nan, 100.0, 100.0], equal_nan=True + ) + + macd_series = layers["macd"]["props"]["series"] + expected_macd = fc.macd_values(close, fast=2, slow=3, signal=2) + assert macd_series["rows"] == 5 + assert macd_series["guides"] == [0.0] + assert macd_series["y_min"] < macd_series["y_max"] + np.testing.assert_allclose(macd_series["macd"], expected_macd["macd"]) + np.testing.assert_allclose(macd_series["signal"], expected_macd["signal"]) + np.testing.assert_allclose(macd_series["histogram"], expected_macd["histogram"]) + + stoch_series = layers["stochastic"]["props"]["series"] + expected_stoch = fc.stochastic_values(high, low, close, k_window=3, d_window=2) + assert stoch_series["rows"] == 5 + assert stoch_series["y_min"] == 0.0 + assert stoch_series["y_max"] == 100.0 + assert stoch_series["guides"] == [20.0, 80.0] + np.testing.assert_allclose(stoch_series["k"], expected_stoch["k"], equal_nan=True) + np.testing.assert_allclose(stoch_series["d"], expected_stoch["d"], equal_nan=True) + + +def test_finance_chart_materializes_bars_pattern_from_source_window(): + x, open_, high, low, close, volume = _ohlcv() + chart = fc.finance_chart( + fc.candlestick( + x=x, + open=open_, + high=high, + low=low, + close=close, + volume=volume, + id="price", + ), + fc.bars_pattern( + source="price", + start=1, + end=3, + destination=(10.0, 200.0), + max_bars=10, + ), + ) + spec, _ = chart.build_payload() + pattern = spec["layers"][0]["props"]["pattern"] + assert pattern["rows"] == 3 + assert pattern["source_start_index"] == 1 + assert pattern["source_end_index"] == 4 + np.testing.assert_allclose(pattern["x"], [10.0, 11.0, 12.0]) + np.testing.assert_allclose(pattern["open"], [200.0, 201.0, 202.0]) + np.testing.assert_allclose(pattern["high"], [202.0, 203.0, 204.0]) + np.testing.assert_allclose(pattern["low"], [198.0, 199.0, 200.0]) + np.testing.assert_allclose(pattern["close"], [201.0, 202.0, 203.0]) + + +def test_finance_chart_materializes_mirrored_flipped_normalized_bars_pattern(): + x, open_, high, low, close, volume = _ohlcv() + chart = fc.finance_chart( + fc.candlestick( + x=x, + open=open_, + high=high, + low=low, + close=close, + volume=volume, + id="price", + ), + fc.bars_pattern( + source="price", + start={"bar": 1}, + end={"bar": 3}, + destination=(20.0, 200.0), + mirrored=True, + flipped=True, + normalize=True, + ), + ) + spec, _ = chart.build_payload() + pattern = spec["layers"][0]["props"]["pattern"] + assert pattern["rows"] == 3 + np.testing.assert_allclose(pattern["x"], [20.0, 21.0, 22.0]) + # Mirrored uses bar 3 as the destination baseline, then normalized percent + # deltas are flipped around the destination price. + np.testing.assert_allclose(pattern["open"], [200.0, 201.9417475728, 203.8834951456]) + np.testing.assert_allclose(pattern["close"], [198.0582524272, 200.0, 201.9417475728]) + assert all( + high_value >= open_value >= low_value + for high_value, open_value, low_value in zip( # noqa: B905 + pattern["high"], pattern["open"], pattern["low"] + ) + ) + + +def test_finance_chart_materializes_deterministic_ghost_feed_from_source(): + x, open_, high, low, close, volume = _ohlcv() + children = ( + fc.candlestick( + x=x, + open=open_, + high=high, + low=low, + close=close, + volume=volume, + id="price", + ), + fc.ghost_feed( + source="price", + anchor=(10.0, 200.0), + direction="up", + bars=4, + avg_hl_ticks=100.0, + variance_ticks=0.0, + seed=123, + ), + ) + first, _ = fc.finance_chart(*children).build_payload() + second, _ = fc.finance_chart(*children).build_payload() + feed = first["layers"][0]["props"]["feed"] + assert feed["rows"] == 4 + assert feed["tick_size"] == 0.04 + np.testing.assert_allclose(feed["x"], [10.0, 11.0, 12.0, 13.0]) + assert all(c > o for o, c in zip(feed["open"], feed["close"], strict=True)) + np.testing.assert_allclose(feed["open"], second["layers"][0]["props"]["feed"]["open"]) + np.testing.assert_allclose(feed["high"], second["layers"][0]["props"]["feed"]["high"]) + + +def test_equity_curve_values_from_returns_and_pnl(): + equity_from_returns = fc.equity_curve_values(returns=[0.10, -0.05, 0.20], initial=100.0) + equity_from_pnl = fc.equity_curve_values(pnl=[10.0, -4.0, 20.0], initial=100.0) + np.testing.assert_allclose(equity_from_returns, [100.0, 110.0, 104.5, 125.4]) + np.testing.assert_allclose(equity_from_pnl, [100.0, 110.0, 106.0, 126.0]) + + +def test_returns_values_simple_and_log(): + values = np.array([100.0, 110.0, 104.5, 125.4]) + np.testing.assert_allclose(fc.returns_values(values), [0.10, -0.05, 0.20]) + np.testing.assert_allclose( + fc.returns_values([100.0, 110.0, 121.0], method="log"), np.log([1.1, 1.1]) + ) + + +def test_drawdown_values_tracks_peak_trough_and_recovery(): + drawdown = fc.drawdown_values([100.0, 110.0, 105.0, 120.0, 90.0, 95.0, 130.0]) + np.testing.assert_allclose( + drawdown["running_peak"], [100.0, 110.0, 110.0, 120.0, 120.0, 120.0, 130.0] + ) + np.testing.assert_allclose(drawdown["drawdown"], [0.0, 0.0, -5.0, 0.0, -30.0, -25.0, 0.0]) + np.testing.assert_allclose( + drawdown["drawdown_pct"], + [0.0, 0.0, -5.0 / 110.0, 0.0, -0.25, -25.0 / 120.0, 0.0], + ) + assert drawdown["max_drawdown"] == -30.0 + assert drawdown["max_drawdown_pct"] == -0.25 + assert drawdown["peak_index"] == 3 + assert drawdown["trough_index"] == 4 + assert drawdown["recovery_index"] == 6 + assert drawdown["drawdown_duration"] == 1 + assert drawdown["recovery_duration"] == 2 + + +def test_equity_drawdown_materializes_stacked_performance_chart(): + chart = fc.performance_chart( + x=np.array([1.0, 2.0, 3.0]), + pnl=np.array([10.0, -15.0, 20.0]), + initial=100.0, + title="strategy", + style={"color": "#155eef", "drawdown_color": "#d92d20"}, + ) + fig = chart.figure() + assert [trace.kind for trace in fig.traces] == ["area"] + assert fig.traces[0].name == "performance" + np.testing.assert_allclose(fig.traces[0].base.values, 100.0) + np.testing.assert_allclose(fig.traces[0].y.values, [100.0, 110.0, 95.0, 115.0]) + np.testing.assert_allclose(fig.traces[0].x.values, [0.0, 1.0, 2.0, 3.0]) + + spec, _ = chart.build_payload() + assert spec["title"] == "strategy" + assert spec["y_axis"]["side"] == "right" + assert spec["traces"][0]["kind"] == "area" + assert [layer["kind"] for layer in spec["layers"]] == ["equity_drawdown"] + layer = spec["layers"][0] + assert layer["props"]["pane"] == "drawdown" + assert layer["props"]["mode"] == "area" + assert layer["style"]["drawdown_color"] == "#d92d20" + series = layer["props"]["series"] + assert series["rows"] == 4 + assert series["drawdown_mode"] == "percent" + np.testing.assert_allclose(series["equity"], [100.0, 110.0, 95.0, 115.0]) + np.testing.assert_allclose(series["running_peak"], [100.0, 110.0, 110.0, 115.0]) + np.testing.assert_allclose(series["drawdown"], [0.0, 0.0, -15.0, 0.0]) + np.testing.assert_allclose(series["drawdown_y"], [0.0, 0.0, -15.0 / 110.0 * 100.0, 0.0]) + assert series["metrics"]["max_drawdown"] == -15.0 + assert series["metrics"]["trough_index"] == 2 + assert series["y_min"] < 0.0 + assert series["y_max"] >= 0.0 + + +def test_var_cvar_values_and_returns_distribution_markers(): + returns = np.array([-0.10, -0.04, -0.02, 0.0, 0.01, 0.03, 0.08]) + risk = fc.var_cvar_values(returns, confidence=0.80) + expected_var = float(np.quantile(returns, 0.20, method="linear")) + assert risk["confidence"] == 0.80 + assert risk["tail_probability"] == pytest.approx(0.20) + assert risk["var"] == pytest.approx(expected_var) + assert risk["cvar"] == pytest.approx(np.mean([-0.10, -0.04])) + assert risk["var_loss"] == pytest.approx(-expected_var) + assert risk["cvar_loss"] == pytest.approx(0.07) + assert risk["tail_count"] == 2 + + distribution = fc.returns_distribution_values( + returns, + bins=4, + bin_range=(-0.10, 0.10), + confidence=0.80, + ) + np.testing.assert_array_equal(distribution["counts"], [1, 2, 3, 1]) + np.testing.assert_allclose(distribution["probability"], [1 / 7, 2 / 7, 3 / 7, 1 / 7]) + np.testing.assert_allclose(distribution["bin_edges"], [-0.10, -0.05, 0.0, 0.05, 0.10]) + np.testing.assert_allclose(distribution["bin_centers"], [-0.075, -0.025, 0.025, 0.075]) + assert distribution["rows"] == 4 + assert distribution["markers"][0]["role"] == "var" + assert distribution["markers"][0]["x"] == pytest.approx(expected_var) + assert distribution["markers"][1]["role"] == "cvar" + assert distribution["markers"][1]["x"] == pytest.approx(-0.07) + + +def test_returns_distribution_chart_materializes_histogram_and_markers(): + returns = np.array([-0.10, -0.04, -0.02, 0.0, 0.01, 0.03, 0.08]) + chart = fc.returns_distribution_chart( + returns, + bins=4, + bin_range=(-0.10, 0.10), + confidence=0.80, + y="probability", + title="risk", + style={"bar_color": "#3366c7", "marker_color": "#dc3038"}, + ) + assert chart.figure().traces == [] + + spec, _ = chart.build_payload() + assert spec["title"] == "risk" + assert spec["traces"] == [] + assert spec["x_axis"]["label"] == "Return" + assert spec["x_axis"]["range"] == [-0.10, 0.10] + assert spec["y_axis"]["label"] == "Probability" + assert spec["y_axis"]["side"] == "right" + + assert [layer["kind"] for layer in spec["layers"]] == ["returns_distribution"] + layer = spec["layers"][0] + assert layer["style"]["bar_color"] == "#3366c7" + assert layer["style"]["marker_color"] == "#dc3038" + series = layer["props"]["series"] + assert series["rows"] == 4 + assert series["y_mode"] == "probability" + assert series["x_min"] == -0.10 + assert series["x_max"] == 0.10 + assert series["y_min"] == 0.0 + assert series["y_max"] > max(series["probability"]) + np.testing.assert_array_equal(series["counts"], [1, 2, 3, 1]) + np.testing.assert_allclose(series["probability"], [1 / 7, 2 / 7, 3 / 7, 1 / 7]) + np.testing.assert_allclose(series["y"], [1 / 7, 2 / 7, 3 / 7, 1 / 7]) + np.testing.assert_allclose(series["bin_edges"], [-0.10, -0.05, 0.0, 0.05, 0.10]) + assert series["markers"][0]["role"] == "var" + assert series["markers"][0]["x"] == pytest.approx(np.quantile(returns, 0.20, method="linear")) + assert series["markers"][1]["role"] == "cvar" + assert series["markers"][1]["x"] == pytest.approx(-0.07) + + +def test_long_position_metrics_match_risk_model(): + pos = fc.long_position( + source="price", + entry=("2026-02-03", 100.0), + stop=95.0, + target=115.0, + account_size=100_000.0, + risk=0.01, + instrument=fc.instrument(tick_size=0.01, point_value=1.0, lot_size=1.0, qty_precision=2), + ) + metrics = pos.metrics() + assert metrics["side"] == "long" + assert metrics["risk_amount"] == 1000.0 + assert metrics["qty_risk"] == 200.0 + assert metrics["qty_leverage"] == 1000.0 + assert metrics["qty"] == 200.0 + assert metrics["risk_reward"] == 3.0 + assert metrics["target_ticks"] == 1500.0 + assert metrics["stop_ticks"] == 500.0 + assert metrics["profit_pnl"] == 3000.0 + assert metrics["loss_pnl"] == -1000.0 + assert metrics["target_account_balance"] == 103_000.0 + assert metrics["stop_account_balance"] == 99_000.0 + + +def test_short_position_metrics_match_risk_model(): + pos = fc.short_position( + source="price", + entry=("2026-02-03", 100.0), + stop=110.0, + target=80.0, + account_size=100_000.0, + risk=1000.0, + risk_mode="amount", + ) + metrics = pos.metrics() + assert metrics["side"] == "short" + assert metrics["risk_amount"] == 1000.0 + assert metrics["qty"] == 100.0 + assert metrics["risk_reward"] == 2.0 + assert metrics["profit_pnl"] == 2000.0 + assert metrics["loss_pnl"] == -1000.0 + + +def test_position_spec_is_serializable_and_preserves_anchors(): + pos = fc.long_position( + source="price", + id="risk-1", + entry=("2026-02-03", 100.0), + stop=95.0, + target=115.0, + end="2026-03-01", + ) + spec = pos.to_spec() + assert spec["role"] == "drawing" + assert spec["kind"] == "position" + assert spec["side"] == "long" + assert spec["id"] == "risk-1" + assert spec["anchors"]["entry"] == {"x": "2026-02-03", "y": 100.0} + assert spec["anchors"]["stop"] == {"y": 95.0} + assert spec["anchors"]["target"] == {"y": 115.0} + assert spec["anchors"]["end"] == {"x": "2026-03-01"} + assert spec["metrics"]["risk_reward"] == 3.0 + + +def test_finance_chart_payload_carries_layers_and_tools(): + chart = fc.finance_chart( + fc.candlestick(*_ohlc(), name="price"), + fc.x_axis(type_="time"), + fc.y_axis(label="price", side="right", type_="linear"), + fc.anchored_vwap(source="price", anchor=(1, 100.0), bands=(1.0, 2.0), id="avwap-1"), + fc.fixed_range_volume_profile(source="price", start=(1, 98.0), end=(4, 108.0), rows=24), + fc.xabcd_pattern( + source="price", + validate="gartley", + points=[(0, 98.0), (1, 106.0), (2, 101.0), (3, 109.0), (4, 103.0)], + ), + fc.finance_tools( + active="long_position", snap="ohlc", selected="avwap-1", on_change=lambda _: None + ), + title="finance", + ) + spec, _ = chart.build_payload() + assert spec["title"] == "finance" + assert spec["y_axis"]["side"] == "right" + assert spec["traces"][0]["kind"] == "candlestick" + assert spec["tools"]["active"] == "long_position" + assert spec["tools"]["snap"] == "ohlc" + assert spec["tools"]["selected"] == "avwap-1" + assert spec["tools"]["events"] == ["change"] + assert [layer["kind"] for layer in spec["layers"]] == [ + "anchored_vwap", + "fixed_range_volume_profile", + "xabcd_pattern", + ] + assert spec["layers"][0]["role"] == "study" + assert spec["layers"][0]["anchors"]["anchor"] == {"x": 1, "y": 100.0} + assert spec["layers"][0]["props"]["bands"] == [1.0, 2.0] + assert spec["layers"][2]["anchors"]["X"] == {"x": 0, "y": 98.0} + assert spec["layers"][2]["props"]["validate"] == "gartley" + + +def test_finance_chart_html_export_keeps_layer_spec(): + chart = fc.finance_chart( + fc.candlestick(*_ohlc(), name="price"), + fc.long_position(source="price", entry=(1, 101.0), stop=98.0, target=108.0, id="risk-1"), + fc.finance_tools(active="long_position"), + title="finance export", + ) + html = chart.to_html() + assert "finance export" in html + assert '"layers":' in html + assert '"kind":"position"' in html + assert '"id":"risk-1"' in html + assert '"tools":' in html + + +def test_forecast_and_measurement_layer_shapes(): + layers = [ + fc.position_forecast(source="price", start=(1, 100.0), target=(5, 120.0)), + fc.bars_pattern(source="price", start=1, end=5, destination=(10, 100.0), flipped=True), + fc.ghost_feed(source="price", anchor=(5, 102.0), bars=12, seed=7), + fc.sector(source="price", origin=(5, 100.0), horizon=10, target=(10, 120.0)), + fc.date_price_range(source="price", start=(1, 100.0), end=(5, 120.0)), + ] + specs = [layer.to_spec() for layer in layers] + assert [spec["kind"] for spec in specs] == [ + "position_forecast", + "bars_pattern", + "ghost_feed", + "sector", + "date_price_range", + ] + assert specs[1]["props"]["flipped"] is True + assert specs[2]["props"]["bars"] == 12 + assert specs[2]["props"]["seed"] == 7 + + +def test_finance_validation_errors(): + with pytest.raises(ValueError, match="long position"): + fc.long_position(source="price", entry=(1, 100.0), stop=105.0, target=115.0) + with pytest.raises(ValueError, match="short position"): + fc.short_position(source="price", entry=(1, 100.0), stop=95.0, target=80.0) + with pytest.raises(ValueError, match="ABCD"): + fc.abcd_pattern(points=[(1, 1.0)]) + with pytest.raises(ValueError, match="XABCD"): + fc.xabcd_pattern(points=[(1, 1.0)]) + with pytest.raises(ValueError, match="value_area"): + fc.fixed_range_volume_profile(source="price", start=1, end=2, value_area=1.5) + with pytest.raises(ValueError, match="direction"): + fc.ghost_feed(source="price", anchor=(1, 100.0), direction="sideways") + with pytest.raises(ValueError, match="method"): + fc.moving_average(source="price", method="wma") + with pytest.raises(ValueError, match="deviations"): + fc.bollinger_bands(source="price", deviations=0.0) + with pytest.raises(ValueError, match="mode"): + fc.equity_drawdown(equity=[1.0, 2.0], mode="bars") + with pytest.raises(ValueError, match="exactly one"): + fc.equity_drawdown(equity=[1.0], pnl=[1.0]) + with pytest.raises(ValueError, match="x"): + fc.equity_drawdown(x=[1.0, 2.0, 3.0], pnl=[1.0], initial=100.0) + with pytest.raises(ValueError, match="window"): + fc.rsi(source="price", window=0) + with pytest.raises(ValueError, match="fast"): + fc.macd(source="price", fast=5, slow=3) + with pytest.raises(ValueError, match="k_window"): + fc.stochastic(source="price", k_window=0) + with pytest.raises(ValueError, match="exactly one"): + fc.equity_curve_values(returns=[0.01], pnl=[1.0]) + with pytest.raises(ValueError, match="confidence"): + fc.var_cvar_values([0.01, -0.01], confidence=1.0) + with pytest.raises(ValueError, match="positive"): + fc.returns_distribution_values([0.01, -0.01], bins=0) + with pytest.raises(ValueError, match="y"): + fc.returns_distribution([0.01, -0.01], y="density") diff --git a/tests/test_type_surface.py b/tests/test_type_surface.py index b65beb5e..b1e63e0f 100644 --- a/tests/test_type_surface.py +++ b/tests/test_type_surface.py @@ -21,6 +21,8 @@ "sankey", "line", "area", + "candlestick", + "ohlc", "histogram", "hist", "bar", @@ -68,6 +70,8 @@ "wind_rose", "line_chart", "area_chart", + "candlestick_chart", + "ohlc_chart", "histogram_chart", "bar_chart", "column_chart", From 7d37d8875e6d62eeb324d596a8dc1990f3590a76 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 2 Aug 2026 13:09:41 -0700 Subject: [PATCH 2/8] Serialize finance indicator gaps safely --- js/src/57_layers.ts | 11 ++++++----- python/xy/finance.py | 3 +++ spec/api/chart-kind-contract.md | 5 +++++ tests/test_finance.py | 29 ++++++++++++++++++++++++++--- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/js/src/57_layers.ts b/js/src/57_layers.ts index 0d42bde1..90568dad 100644 --- a/js/src/57_layers.ts +++ b/js/src/57_layers.ts @@ -346,6 +346,7 @@ function drawAnchoredStudy(view, ctx, layer) { } function layerNumber(v) { + if (v === null || v === undefined || v === "") return NaN; const n = Number(v); if (Number.isFinite(n)) return n; const t = Date.parse(v); @@ -370,7 +371,7 @@ function oscillatorRange(series, keys, fallback) { for (const key of keys) { const arr = Array.isArray(series && series[key]) ? series[key] : []; for (const raw of arr) { - const v = Number(raw); + const v = layerNumber(raw); if (!Number.isFinite(v)) continue; yMin = Math.min(yMin, v); yMax = Math.max(yMax, v); @@ -455,7 +456,7 @@ function drawPaneLine(view, ctx, pane, xs, values, yMin, yMax, color, width = 1. const n = Math.min(xs.length, values.length); for (let i = 0; i < n; i++) { const x = view._dataToScreenX(layerNumber(xs[i])); - const value = Number(values[i]); + const value = layerNumber(values[i]); if (!Number.isFinite(x) || !Number.isFinite(value)) { started = false; continue; @@ -481,13 +482,13 @@ function drawMacdHistogram(view, ctx, layer, pane, series, yMin, yMax) { let visible = 0; for (let i = 0; i < Math.min(xs.length, hist.length); i++) { const x = layerNumber(xs[i]); - const h = Number(hist[i]); + const h = layerNumber(hist[i]); if (Number.isFinite(x) && Number.isFinite(h) && x >= view.view.x0 && x <= view.view.x1) visible++; } ctx.save(); for (let i = 0; i < Math.min(xs.length, hist.length); i++) { const x = view._dataToScreenX(layerNumber(xs[i])); - const h = Number(hist[i]); + const h = layerNumber(hist[i]); if (!Number.isFinite(x) || !Number.isFinite(h)) continue; const y = paneY(pane, h, yMin, yMax); const w = paneSlotWidth(view, pane, xs, i, visible); @@ -506,7 +507,7 @@ function drawPaneFilledLine(view, ctx, pane, xs, values, yMin, yMax, lineColor, const n = Math.min(xs.length, values.length); for (let i = 0; i < n; i++) { const x = view._dataToScreenX(layerNumber(xs[i])); - const value = Number(values[i]); + const value = layerNumber(values[i]); if (!Number.isFinite(x) || !Number.isFinite(value)) continue; pts.push([x, paneY(pane, value, yMin, yMax)]); } diff --git a/python/xy/finance.py b/python/xy/finance.py index 3d0df596..5650a9b9 100644 --- a/python/xy/finance.py +++ b/python/xy/finance.py @@ -30,6 +30,9 @@ def _jsonable(value: Any) -> Any: return [_jsonable(v) for v in value.tolist()] if isinstance(value, np.bool_): return bool(value) + if isinstance(value, (float, np.floating)): + number = float(value) + return number if math.isfinite(number) else None if isinstance(value, Mapping): return {str(k): _jsonable(v) for k, v in value.items() if v is not None} if isinstance(value, (list, tuple)): diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index 5413c945..4e81007e 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -304,6 +304,11 @@ parallel layer registry rather than branching the mark render loop or inflating `candlestick()`. See [`docs/quant-finance-roadmap.md`](../../docs/quant-finance-roadmap.md) for the detailed API and implementation plan. +Derived finance-study arrays use JSON `null` for non-finite warm-up values; +layer renderers treat those entries as gaps rather than numeric zeroes. This +keeps the layer spec valid finite JSON for standalone export without inventing +indicator values before a rolling window is populated. + ## Registry capabilities Beyond `build`/`draw`, `MARK_KINDS` entries carry capability flags/hooks so no diff --git a/tests/test_finance.py b/tests/test_finance.py index 195d5d55..dfdb1b07 100644 --- a/tests/test_finance.py +++ b/tests/test_finance.py @@ -22,6 +22,10 @@ def _ohlcv(): return x, open_, high, low, close, volume +def _float_values(values): + return np.asarray([np.nan if value is None else value for value in values], dtype=np.float64) + + def test_finance_factories_return_components(): assert isinstance(fc.instrument(), Instrument) assert isinstance(fc.finance_tools(), FinanceTools) @@ -320,7 +324,9 @@ def test_finance_chart_materializes_oscillator_layers_from_ohlcv_source(): assert rsi_series["guides"] == [30.0, 70.0] np.testing.assert_allclose(rsi_series["x"], x) np.testing.assert_allclose( - rsi_series["rsi"], [np.nan, np.nan, np.nan, 100.0, 100.0], equal_nan=True + _float_values(rsi_series["rsi"]), + [np.nan, np.nan, np.nan, 100.0, 100.0], + equal_nan=True, ) macd_series = layers["macd"]["props"]["series"] @@ -338,8 +344,12 @@ def test_finance_chart_materializes_oscillator_layers_from_ohlcv_source(): assert stoch_series["y_min"] == 0.0 assert stoch_series["y_max"] == 100.0 assert stoch_series["guides"] == [20.0, 80.0] - np.testing.assert_allclose(stoch_series["k"], expected_stoch["k"], equal_nan=True) - np.testing.assert_allclose(stoch_series["d"], expected_stoch["d"], equal_nan=True) + np.testing.assert_allclose( + _float_values(stoch_series["k"]), expected_stoch["k"], equal_nan=True + ) + np.testing.assert_allclose( + _float_values(stoch_series["d"]), expected_stoch["d"], equal_nan=True + ) def test_finance_chart_materializes_bars_pattern_from_source_window(): @@ -704,6 +714,19 @@ def test_finance_chart_html_export_keeps_layer_spec(): assert '"tools":' in html +def test_finance_chart_html_export_serializes_indicator_warmup_as_gaps(): + x, open_, high, low, close, volume = _ohlcv() + chart = fc.finance_chart( + fc.candlestick(x, open_, high, low, close, volume=volume, id="price"), + fc.rsi(source="price", window=3), + fc.stochastic(source="price", k_window=3, d_window=2), + ) + html = chart.to_html() + assert '"rsi":[null,null,null,100.0,100.0]' in html + assert '"k":[null,null,' in html + assert '"d":[null,null,null,' in html + + def test_forecast_and_measurement_layer_shapes(): layers = [ fc.position_forecast(source="price", start=(1, 100.0), target=(5, 120.0)), From c378f494ca13fad195ff28837c32ea6b3a1ba19d Mon Sep 17 00:00:00 2001 From: Alek Date: Sun, 2 Aug 2026 14:36:44 -0700 Subject: [PATCH 3/8] Preserve finance payloads in Reflex updates --- python/reflex_xy/assets/XYChart.jsx | 13 ++++++--- python/reflex_xy/registry.py | 35 ++++++++++++++++++++++- tests/reflex_adapter/test_figure_var.py | 25 ++++++++++++++++ tests/test_tailwind_root_customization.py | 12 ++++++++ 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/python/reflex_xy/assets/XYChart.jsx b/python/reflex_xy/assets/XYChart.jsx index 11513693..5389364b 100644 --- a/python/reflex_xy/assets/XYChart.jsx +++ b/python/reflex_xy/assets/XYChart.jsx @@ -175,10 +175,13 @@ const axisLayoutSpec = (spec) => { }; // updatePayload owns new axes/ranges, trace buffers, marks, annotations, -// tooltip content, and animation. The fields below instead determine DOM -// topology or layout built only by the ChartView constructor. Projecting just -// those inputs preserves the fast path for an ordinary data-only publish while -// still rebuilding title/legend/colorbar/badge/modebar/axis-band chrome. +// tooltip content, and animation. Finance layers are different: ChartView's +// constructor derives its volume/oscillator pane layout from them, and finance +// tool state is likewise constructor-owned. Include both in this signature so +// a state-driven study/drawing/tool change takes the safe full-remount path +// instead of keeping the preceding payload's layer list and pane geometry. +// Projecting only constructor-owned inputs still preserves the fast path for +// ordinary data-only publishes. const mountedChromeSpec = (spec) => ({ dom: spec?.dom ?? null, title: spec?.title ?? null, @@ -193,6 +196,8 @@ const mountedChromeSpec = (spec) => ({ export: spec?.export ?? null, interaction: spec?.interaction ?? null, axes: axisLayoutSpec(spec), + layers: spec?.layers ?? null, + tools: spec?.tools ?? null, }); const sameMountedChromeSpec = (left, right) => diff --git a/python/reflex_xy/registry.py b/python/reflex_xy/registry.py index 8986923d..7f7d4e06 100644 --- a/python/reflex_xy/registry.py +++ b/python/reflex_xy/registry.py @@ -746,9 +746,42 @@ def reset_registry_for_tests() -> FigureRegistry: return registry +class _PayloadFigureAdapter: + """Keep a chart's richer payload while delegating figure kernels. + + Most public charts are thin factories whose ``figure()`` result owns the + complete wire payload. FinanceChart is deliberately different: its base + Figure owns the ordinary traces, while ``FinanceChart.build_payload()`` + adds studies, drawings, tools, panes, and their computed axis ranges. A + figure var therefore needs to publish that richer payload without making + FinanceChart reimplement every interaction kernel on Figure. + """ + + def __init__(self, chart: Any) -> None: + self._chart = chart + self._figure = chart.figure() + + def build_payload(self, px_width: Any = None): + if px_width is None: + return self._chart.build_payload() + return self._chart.build_payload(px_width) + + def build_payload_split(self, px_width: Any = None): + # Finance-layer arrays are JSON materialized today. Preserve the + # chart's joined buffer layout as one Socket.IO attachment rather than + # asking the base Figure for a split payload that omits those layers. + spec, blob = self.build_payload(px_width) + return spec, [blob] + + def __getattr__(self, name: str) -> Any: + return getattr(self._figure, name) + + def _figure_of(chart: Any) -> "Figure": - """Accept either a public `xy.Chart` or an internal Figure.""" + """Accept a public chart, a richer payload chart, or an internal Figure.""" figure = getattr(chart, "figure", None) if callable(figure): + if callable(getattr(chart, "build_payload", None)): + return _PayloadFigureAdapter(chart) # type: ignore[return-value] return figure() return chart diff --git a/tests/reflex_adapter/test_figure_var.py b/tests/reflex_adapter/test_figure_var.py index 3c83f250..67306ca0 100644 --- a/tests/reflex_adapter/test_figure_var.py +++ b/tests/reflex_adapter/test_figure_var.py @@ -33,6 +33,16 @@ def maybe_chart(self): xs = np.linspace(0.0, 1.0, 4) return xy.line_chart(xy.line(xs, xs), width=300, height=200) + @reflex_xy.figure + def finance_chart(self): + returns = np.linspace(-0.03, 0.025, self.n) + return xy.returns_distribution_chart( + returns, + bins=12, + confidence=0.95, + title="risk", + ) + def hydrated_substate(client_token: str) -> VarDemo: root = rx.State(_reflex_internal_init=True) @@ -69,6 +79,21 @@ def test_dep_change_keeps_token_bumps_version(_fresh_registry, client_token): assert entry.figure.traces[0].n_points == 250 +def test_finance_figure_keeps_layers_in_registered_payload(_fresh_registry, client_token): + state = hydrated_substate(client_token) + token = state.finance_chart + entry = _fresh_registry.get(token) + assert entry is not None + + spec, buffers = entry.figure.build_payload_split() + assert spec["title"] == "risk" + assert spec["traces"] == [] + assert [layer["kind"] for layer in spec["layers"]] == ["returns_distribution"] + assert spec["layers"][0]["props"]["series"]["rows"] == 12 + assert spec["x_axis"]["range"] == [-0.03, 0.025] + assert len(buffers) == 1 + + def test_recompute_broadcasts_to_publish_hook(_fresh_registry, client_token): published: list[tuple[str, int]] = [] diff --git a/tests/test_tailwind_root_customization.py b/tests/test_tailwind_root_customization.py index 986e7775..aa2531c4 100644 --- a/tests/test_tailwind_root_customization.py +++ b/tests/test_tailwind_root_customization.py @@ -399,6 +399,8 @@ def test_live_wrapper_rebuilds_constructor_owned_chrome_only_when_needed() -> No "export:", "interaction:", "axes:", + "layers:", + "tools:", ): assert field in jsx # Trace buffers/columns and axis ranges stay outside the mounted-chrome @@ -416,6 +418,16 @@ def test_live_wrapper_rebuilds_constructor_owned_chrome_only_when_needed() -> No ) +def test_live_wrapper_remounts_when_finance_layers_or_tools_change() -> None: + jsx = (ROOT / "python" / "reflex_xy" / "assets" / "XYChart.jsx").read_text(encoding="utf-8") + + mounted = jsx.split("const mountedChromeSpec = (spec) => ({", 1)[1].split("});", 1)[0] + assert "layers: spec?.layers ?? null" in mounted + assert "tools: spec?.tools ?? null" in mounted + assert "const chromeChanged = Boolean(view && !sameMountedChromeSpec(view.spec, spec));" in jsx + assert "if (!chromeChanged && view?.updatePayload?.(spec, nextBuffers)) {" in jsx + + def test_live_wrapper_silently_hydrates_durable_selection_and_all_axis_ranges() -> None: jsx = (ROOT / "python" / "reflex_xy" / "assets" / "XYChart.jsx").read_text(encoding="utf-8") From 9a2422e6e2650b859c3009019ee1c50a8dde674d Mon Sep 17 00:00:00 2001 From: Alek Date: Sun, 2 Aug 2026 14:36:56 -0700 Subject: [PATCH 4/8] Add finance terminal Reflex example --- examples/reflex/.gitignore | 1 + examples/reflex/README.md | 169 +- examples/reflex/rxconfig.py | 1 + examples/reflex/xy_reflex_demo/__init__.py | 2 +- examples/reflex/xy_reflex_demo/charts.py | 720 +++++++++ examples/reflex/xy_reflex_demo/components.py | 1412 +++++++++++++++++ examples/reflex/xy_reflex_demo/data.py | 927 +++++++++++ examples/reflex/xy_reflex_demo/state.py | 433 +++++ .../reflex/xy_reflex_demo/xy_reflex_demo.py | 721 +-------- tests/test_example_apps.py | 275 +++- 10 files changed, 3839 insertions(+), 822 deletions(-) create mode 100644 examples/reflex/xy_reflex_demo/charts.py create mode 100644 examples/reflex/xy_reflex_demo/components.py create mode 100644 examples/reflex/xy_reflex_demo/data.py create mode 100644 examples/reflex/xy_reflex_demo/state.py diff --git a/examples/reflex/.gitignore b/examples/reflex/.gitignore index fc4eef99..dd2a3fdf 100644 --- a/examples/reflex/.gitignore +++ b/examples/reflex/.gitignore @@ -8,5 +8,6 @@ __pycache__/ .venv/ uv.lock reflex.lock/ +reflex.log assets/external/ assets/xy/ diff --git a/examples/reflex/README.md b/examples/reflex/README.md index 0fcd1ee9..07d71665 100644 --- a/examples/reflex/README.md +++ b/examples/reflex/README.md @@ -1,80 +1,113 @@ -# XY Reflex showcase - -A [Reflex](https://reflex.dev) app built with the `xy[reflex]` integration. -One page walks through the ways to link chart data into a Reflex app, and each -section carries a **Code** accordion showing its source via -`inspect.getsource`. - -Chart data rides the app's own websocket as a second socket.io namespace of -binary columns; Reflex state holds only a token string per chart. - -## What it shows - -1. **Live figure var + events** — a 1M-point drillable scatter from an - `@reflex_xy.figure` method, with `on_point_hover` / `on_point_click` / - `on_select_end` handlers. -2. **A chart driven by state vars** — a histogram whose bin count is a slider - and whose data is cross-filtered by the selection above; changing either - recomputes and re-publishes the figure under a stable token. -3. **A dynamically updating chart** — a line grown by a background task via - `reflex_xy.append`. -4. **Data computed from `on_view_change`** — pan/zoom an overview and a detail - histogram recomputes from the points in the reported window. -5. **Fixed data, two ways** — a `xy.Chart` passed straight to `reflex_xy.chart` - (static payload tier) and a `reflex_xy.inline` token (fixed data served - through the kernel). -6. **The 100M drilldown, adapter-native** — the live drilldown scatter - from [`examples/fastapi`](../fastapi) (identical seed-11 data and mark - config, a density surface that drills into exact points on zoom) as a - single `reflex_xy.inline` token. The FastAPI app hand-rolls its transport - for this chart (a Starlette endpoint plus an HTTP comm bridge); here the - adapter's websocket namespace and the kernel's density tiers do all of it, - so behavioral differences between the two apps isolate what that custom - code adds. +# XY TERMINAL + +XY TERMINAL is a dense, professional-market workstation built entirely in +[Reflex](https://reflex.dev) with the `xy[reflex]` integration. It demonstrates +finance charts, state-driven figures, fixed and streamed data, and semantic +chart events in one responsive page. + +> **SIMULATED DATA** — every quote, price series, position, news story, +> economic event, and risk result in this example is fictional and generated +> locally from fixed seeds. The app does not contact a market-data service, +> submit orders, or require an API key. It is an interface and charting demo, +> not investment advice. + +The black-and-amber visual language is inspired by professional market +terminals, but the app does not use third-party brand names, logos, assets, or data. + +## Workspaces + +- **Markets (`MKTS`)** — a landing-page SPY `FinanceChart` with native OHLCV, + studies, oscillator, projection, and finance tools, plus cross-asset quotes, + movers, breadth, a market heatmap, the yield curve, and a live pulse fed + through `reflex_xy.append()`. +- **Security (`DES `)** — daily or weekly OHLCV, range controls, + overlays, oscillator panes, finance drawing presets, key statistics, + related stories, and a paper-only position-risk ticket. +- **Portfolio (`PORT`)** — deterministic positions, NAV and P&L, equity and + drawdown, allocation, contribution, and exposure. Choosing a position opens + its Security workspace. +- **Risk (`RISK`)** — return distribution with VaR/CVaR, correlations, factor + exposure, confidence controls, and deterministic stress scenarios. +- **News (`NEWS`)** — simulated stories with sentiment and impact metadata, + story detail, and a fictional economic calendar. + +The persistent shell also includes a ticker tape, watchlist, context rail, +function-key navigation, status line, and a developer drawer. The drawer shows +live Python source, a compact Reflex-state snapshot, and an abbreviated XY +chart/layer specification. + +## Commands + +Type a command in the top command bar and press Enter: + +| Command | Result | +| --- | --- | +| `MKTS` | Open Markets | +| `DES AAPL` | Open the Security workspace for a known symbol | +| `PORT` | Open Portfolio | +| `RISK` | Open Risk | +| `NEWS` | Open News | +| `HELP` | Show the command reference | + +Commands and symbols are case-insensitive. Unknown input stays in the app and +produces an inline status message. ## Run +From this directory: + ```bash cd examples/reflex uv run reflex run ``` -`uv run` resolves this directory's [`pyproject.toml`](pyproject.toml) -(`xy[reflex]`) into a local environment. Open the URL Reflex prints (usually -). Zoom into the cloud to drill density into exact -points; box-select to cross-filter the histogram; press **go live** to stream. +`uv run` resolves this directory's [`pyproject.toml`](pyproject.toml), including +the editable local `xy[reflex]` package. Open the URL printed by Reflex +(normally ). No environment variables or external +services are required. + +## Architecture + +The `xy_reflex_demo` package is split by responsibility: + +- `data.py` defines typed instrument, quote, position, story, calendar, and + scenario models. Cached NumPy generators create three years of seeded daily + OHLCV as of the fixed date displayed in the app. +- `charts.py` contains pure data transforms and chart builders for all five + workspaces, including finance studies and drawings. +- `state.py` keeps only small UI selections and inputs in Reflex state. It + owns command routing, semantic chart events, paper-ticket validation, and + one guarded background quote loop. +- `components.py` composes the persistent terminal shell and responsive + workspace views; the package entry point registers the single page. + +State-dependent Security, Portfolio, and Risk charts use +`@reflex_xy.figure`. The first Markets panel is a direct, fixed-data +`xy.FinanceChart`, so the new finance surface is visible immediately rather +than only after a Security drilldown. Other fixed views exercise a direct +`xy.Chart` and the kernel-backed `reflex_xy.inline()` tier. The live pulse +starts with a figure token and receives compact points through +`reflex_xy.append()`. Hover and view-change events are handled as ordinary +Reflex events; there is no iframe or `postMessage` bridge. -`XY_LIVE_POINTS` sets §6's point count — the same override the FastAPI app -honors, so both apps build the identical dataset at any size. Unlike the -FastAPI app (lazy, on first use) the columns are built at import, because -`inline()` registers at module scope; the default 100M costs a few gigabytes -of RAM and some startup seconds, so dial it down on small machines: +The adapter is enabled by `reflex_xy.XYPlugin()` in +[`rxconfig.py`](rxconfig.py). Chart payloads travel through the app's XY +websocket namespace while Reflex state retains only lightweight selections +and token strings. + +## Paper ticket + +The Security ticket accepts side, entry, stop, target, account size, and risk +percentage. A valid setup updates the long/short chart overlay and displays +risk, quantity, and reward/risk metrics. Invalid ordering is explained inline +and suppresses the overlay. The button does not place or simulate an order. + +## Checks + +From the repository root, the focused test covers deterministic data, OHLC +invariants, portfolio/risk calculations, representative chart specs, linking +tiers, semantic events, and app composition: ```bash -XY_LIVE_POINTS=1000000 uv run reflex run +uv run pytest tests/test_example_apps.py -q ``` - -The adapter is wired in one line — `plugins=[reflex_xy.XYPlugin()]` in -[`rxconfig.py`](rxconfig.py). - -## Interaction contract checks - -Section 1's badges are event counters, and its click/select handlers -deliberately republish the cloud behind its stable token (the title's -`handler revision`). Together they make the wrapper's restore contract -manually verifiable: - -1. Box-select a large area. The `select` readout shows the exact total, the - bounded JSON row count, and `truncated`; the §2 histogram cross-filters. - The cloud must keep both its viewport and its selection highlight across - the republish, and the selection counter must increment exactly once. -2. Zoom until density drills into exact points, then click one. The `click` - readout shows its canonical row ID, f64 data coordinates, and active - keyboard modifiers; the click counter must increment exactly once. -3. Focus a point and press Enter or Space. Keyboard activation must produce - the same click readout contract as pointer activation. -4. Clear the selection. The histogram returns to all points and the select - counter increments exactly once again. - -A runaway counter or a viewport/selection reset after any of these reveals a -republish feedback loop or a restore regression. diff --git a/examples/reflex/rxconfig.py b/examples/reflex/rxconfig.py index a4952b59..53bf932d 100644 --- a/examples/reflex/rxconfig.py +++ b/examples/reflex/rxconfig.py @@ -5,6 +5,7 @@ config = rx.Config( app_name="xy_reflex_demo", plugins=[ + rx.plugins.RadixThemesPlugin(), rx.plugins.SitemapPlugin(), reflex_xy.XYPlugin(), ], diff --git a/examples/reflex/xy_reflex_demo/__init__.py b/examples/reflex/xy_reflex_demo/__init__.py index 82088d98..1611fa81 100644 --- a/examples/reflex/xy_reflex_demo/__init__.py +++ b/examples/reflex/xy_reflex_demo/__init__.py @@ -1 +1 @@ -"""XY Reflex showcase app.""" +"""Deterministic terminal example data, charts, and Reflex application.""" diff --git a/examples/reflex/xy_reflex_demo/charts.py b/examples/reflex/xy_reflex_demo/charts.py new file mode 100644 index 00000000..d027d879 --- /dev/null +++ b/examples/reflex/xy_reflex_demo/charts.py @@ -0,0 +1,720 @@ +"""Pure :mod:`xy` chart builders for the simulated terminal example. + +The Reflex app wraps state-dependent builders with ``@reflex_xy.figure`` and +chooses whether fixed charts travel as direct payloads or ``inline()`` tokens. +Keeping this module framework-neutral makes the data and finance composition +cheap to test without starting a server. +""" + +from __future__ import annotations + +import math +from collections.abc import Iterable, Mapping +from typing import Any + +import numpy as np + +import xy + +from . import data + +_BG = "#050505" +_PLOT_BG = "#090806" +_AMBER = "#f6c453" +_AMBER_DIM = "#9b7428" +_GRID = "#30240d" +_GREEN = "#35d07f" +_RED = "#ff5a5f" +_BLUE = "#5aa9ff" +_VIOLET = "#b892ff" + +_FINANCE_STYLE = { + "background": _BG, + "color": _AMBER, + "--chart-bg": _PLOT_BG, + "--chart-grid": _GRID, + "--chart-axis": _AMBER_DIM, + "--chart-text": _AMBER, + "--chart-crosshair": "#ffe19a", + "--chart-tooltip-bg": "#17130a", + "--chart-tooltip-text": "#fff1c2", +} + + +def _theme() -> xy.Theme: + return xy.theme( + background=_BG, + plot_background=_PLOT_BG, + grid_color=_GRID, + axis_color=_AMBER_DIM, + text_color=_AMBER, + crosshair_color="#ffe19a", + tooltip_bg="#17130a", + tooltip_text="#fff1c2", + palette=[_AMBER, _GREEN, _BLUE, _VIOLET, _RED, "#70d6ff"], + ) + + +def market_heatmap_chart() -> xy.Chart: + symbols, ranges, values = data.market_heatmap_data() + bound = max(1.0, float(np.max(np.abs(values)))) + return xy.heatmap_chart( + xy.heatmap( + values, + x=symbols, + y=ranges, + name="return %", + colormap="spectral", + domain=(-bound, bound), + ), + xy.x_axis(tick_label_angle=-34, tick_label_anchor="end"), + xy.y_axis(label="window"), + xy.colorbar(title="return %"), + _theme(), + title="CROSS-ASSET RETURN MAP · SIMULATED", + width="100%", + height=260, + ) + + +def yield_curve_chart() -> xy.Chart: + tenors, years, rates = data.yield_curve() + return xy.line_chart( + xy.line(years, rates, name="Treasury", color=_AMBER, width=2.0), + xy.scatter(years, rates, name="tenors", color=_GREEN, size=7.0, opacity=0.95), + xy.x_axis(label="maturity", tick_values=years, tick_labels=tenors), + xy.y_axis(label="yield (%)", side="right", format=".2f"), + xy.legend(show=False), + _theme(), + title="SIMULATED U.S. TREASURY CURVE", + width="100%", + height=260, + ) + + +def market_pulse_chart() -> xy.Chart: + x, values = data.pulse_seed() + return xy.line_chart( + xy.line(x, values, name="pulse", color=_AMBER, width=1.8), + xy.x_axis(show=False), + xy.y_axis(label="normalized", side="right", tick_count=4), + xy.legend(show=False), + _theme(), + title="LIVE MARKET PULSE · SIMULATED", + width="100%", + height=190, + padding=(22, 44, 28, 14), + ) + + +def market_focus_chart() -> Any: + """Build the landing-page finance chart from the native finance surface. + + The Markets workspace is the first screen a visitor sees, so it should not + make the flagship ``FinanceChart`` look like a hidden Security-only detail. + This fixed SPY view deliberately exercises the same candlestick, study, + oscillator, projection, and finance-tool payload used by the state-backed + Security workspace. + """ + + return security_chart( + "SPY", + range_key="6M", + resolution="1D", + overlays=("SMA 20", "Anchored VWAP", "Volume Profile"), + oscillator="MACD", + drawing="Forecast", + ) + + +def _canonical(value: str) -> str: + return "".join(character for character in value.lower() if character.isalnum()) + + +_OVERLAYS = { + "sma20": "sma20", + "ema50": "ema50", + "bollinger": "bollinger", + "bollingerbands": "bollinger", + "vwap": "vwap", + "anchoredvwap": "anchored_vwap", + "avwap": "anchored_vwap", + "volumeprofile": "volume_profile", + "anchoredvolumeprofile": "volume_profile", +} +_OSCILLATORS = { + "": "none", + "none": "none", + "rsi": "rsi", + "macd": "macd", + "stochastic": "stochastic", +} +_DRAWINGS = { + "": "none", + "none": "none", + "long": "long_position", + "longposition": "long_position", + "short": "short_position", + "shortposition": "short_position", + "forecast": "forecast", + "positionforecast": "forecast", + "barspattern": "bars_pattern", + "ghost": "ghost_feed", + "ghostfeed": "ghost_feed", + "xabcd": "xabcd", + "xabcdpattern": "xabcd", +} + + +def _number(ticket: Mapping[str, Any], key: str) -> float: + try: + value = float(ticket[key]) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError(f"{key.replace('_', ' ')} must be a number") from exc + if not math.isfinite(value): + raise ValueError(f"{key.replace('_', ' ')} must be finite") + return value + + +def _ticket_drawing( + ticket: Mapping[str, Any], + *, + anchor: Any = (0.0, 1.0), + end: Any | None = None, +) -> Any: + side = str(ticket.get("side", "long")).lower().strip() + if side not in {"long", "short"}: + raise ValueError("side must be long or short") + entry = _number(ticket, "entry") + stop = _number(ticket, "stop") + target = _number(ticket, "target") + account_size = _number(ticket, "account_size") + risk_percent = _number(ticket, "risk_percent") + if account_size <= 0: + raise ValueError("account size must be positive") + if not 0 < risk_percent <= 100: + raise ValueError("risk percent must be greater than 0 and at most 100") + symbol = str(ticket.get("symbol", "SPY")) + meta = data.instrument(symbol) + qty_precision = 4 if meta.asset_class in {"FX", "Crypto"} else 2 + instrument = xy.instrument( + tick_size=meta.tick_size, + point_value=1.0, + lot_size=1.0, + qty_precision=qty_precision, + currency=meta.currency, + ) + kwargs = { + "source": "price", + "entry": (anchor, entry) if not isinstance(anchor, tuple) else (anchor[0], entry), + "stop": stop, + "target": target, + "account_size": account_size, + "risk": risk_percent / 100.0, + "risk_mode": "fraction", + "instrument": instrument, + "id": "paper-risk", + "style": {"profit_color": _GREEN, "loss_color": _RED, "text_color": _AMBER}, + } + if end is not None: + kwargs["end"] = end + return xy.long_position(**kwargs) if side == "long" else xy.short_position(**kwargs) + + +def ticket_metrics(ticket: Mapping[str, Any] | None) -> dict[str, Any]: + """Validate a paper ticket and return a small JSON-safe metric mapping.""" + + if not ticket: + return {"valid": False, "error": "Complete the paper ticket to preview risk."} + try: + drawing = _ticket_drawing(ticket) + metrics = drawing.metrics() + except (TypeError, ValueError) as exc: + return {"valid": False, "error": str(exc)} + return { + "valid": True, + "error": "", + "side": metrics["side"], + "entry": float(metrics["entry"]), + "stop": float(metrics["stop"]), + "target": float(metrics["target"]), + "account_size": float(metrics["account_size"]), + "risk_percent": float(ticket["risk_percent"]), + "risk_amount": float(metrics["risk_amount"]), + "quantity": float(metrics["qty_display"]), + "risk_reward": float(metrics["risk_reward"]), + "reward_risk": float(metrics["risk_reward"]), + "profit_pnl": float(metrics["profit_pnl"]), + "loss_pnl": float(metrics["loss_pnl"]), + } + + +def _default_ticket(symbol: str, side: str, last: float) -> dict[str, Any]: + if side == "long": + stop, target = last * 0.97, last * 1.06 + else: + stop, target = last * 1.03, last * 0.94 + return { + "symbol": symbol, + "side": side, + "entry": last, + "stop": stop, + "target": target, + "account_size": 100_000.0, + "risk_percent": 1.0, + } + + +def _future_date(days: int) -> str: + value = np.datetime64(data.AS_OF.isoformat(), "D") + np.timedelta64(days, "D") + return str(np.datetime_as_string(value, unit="D")) + + +def security_chart( + symbol: str, + range_key: str = "1Y", + resolution: str = "1D", + overlays: Iterable[str] = (), + oscillator: str = "None", + drawing: str = "None", + ticket: Mapping[str, Any] | None = None, +) -> Any: + """Build the state-dependent OHLCV finance chart for a security workspace.""" + + values = data.history(symbol, resolution=resolution, range_key=range_key) + meta = data.instrument(symbol) + overlay_values = (overlays,) if isinstance(overlays, str) else tuple(overlays) + normalized_overlays: list[str] = [] + for item in overlay_values: + try: + normalized = _OVERLAYS[_canonical(str(item))] + except KeyError as exc: + raise ValueError(f"unknown finance overlay {item!r}") from exc + if normalized not in normalized_overlays: + normalized_overlays.append(normalized) + try: + normalized_oscillator = _OSCILLATORS[_canonical(oscillator)] + except KeyError as exc: + raise ValueError(f"unknown oscillator {oscillator!r}") from exc + try: + normalized_drawing = _DRAWINGS[_canonical(drawing)] + except KeyError as exc: + raise ValueError(f"unknown drawing preset {drawing!r}") from exc + + layers: list[Any] = [ + xy.volume_bars( + source="price", + pane="volume", + id="volume", + style={"up_color": _GREEN, "down_color": _RED, "opacity": 0.62}, + ) + ] + for overlay in normalized_overlays: + if overlay == "sma20": + layers.append( + xy.moving_average( + source="price", + window=20, + method="sma", + id="SMA 20", + style={"color": _BLUE, "width": 1.35}, + ) + ) + elif overlay == "ema50": + layers.append( + xy.moving_average( + source="price", + window=50, + method="ema", + id="EMA 50", + style={"color": _VIOLET, "width": 1.35}, + ) + ) + elif overlay == "bollinger": + layers.append( + xy.bollinger_bands( + source="price", + window=20, + deviations=2.0, + id="Bollinger", + style={"color": "#70d6ff", "band_opacity": 0.44}, + ) + ) + elif overlay == "vwap": + layers.append( + xy.vwap(source="price", id="VWAP", style={"color": _GREEN, "width": 1.45}) + ) + elif overlay == "anchored_vwap": + layers.append( + xy.anchored_vwap( + source="price", + anchor={"bar": max(0, len(values) - 80)}, + bands=(1.0,), + id="Anchored VWAP", + style={"color": _AMBER, "band_opacity": 0.42}, + ) + ) + elif overlay == "volume_profile": + layers.append( + xy.anchored_volume_profile( + source="price", + anchor={"bar": max(0, len(values) - 120)}, + rows=30, + volume="up_down", + value_area=0.70, + id="Volume profile", + style={"up_color": _GREEN, "down_color": _RED}, + ) + ) + + if normalized_oscillator == "rsi": + layers.append( + xy.rsi(source="price", pane="oscillator", id="RSI 14", style={"color": _AMBER}) + ) + elif normalized_oscillator == "macd": + layers.append( + xy.macd( + source="price", + pane="oscillator", + id="MACD", + style={"macd_color": _BLUE, "signal_color": _AMBER}, + ) + ) + elif normalized_oscillator == "stochastic": + layers.append( + xy.stochastic( + source="price", + pane="oscillator", + id="Stochastic", + style={"k_color": _AMBER, "d_color": _VIOLET}, + ) + ) + + last = float(values.close[-1]) + if normalized_drawing in {"long_position", "short_position"}: + selected_side = "long" if normalized_drawing == "long_position" else "short" + selected_ticket = dict(ticket or _default_ticket(meta.symbol, selected_side, last)) + selected_ticket["side"] = selected_side + selected_ticket.setdefault("symbol", meta.symbol) + if ticket_metrics(selected_ticket)["valid"]: + start_index = max(0, len(values) - 32) + layers.append( + _ticket_drawing( + selected_ticket, + anchor=str(values.dates[start_index]), + end=_future_date(21), + ) + ) + elif normalized_drawing == "forecast": + start_index = max(0, len(values) - 24) + layers.append( + xy.position_forecast( + source="price", + start=(str(values.dates[start_index]), float(values.close[start_index])), + target=(_future_date(35), last * 1.08), + id="forecast", + style={"color": _AMBER, "fill_color": "rgba(246,196,83,0.12)"}, + ) + ) + elif normalized_drawing == "bars_pattern": + layers.append( + xy.bars_pattern( + source="price", + start={"bar": max(0, len(values) - 64)}, + end={"bar": max(0, len(values) - 40)}, + destination=(_future_date(7), last), + normalize=True, + max_bars=30, + id="bars-pattern", + style={"up_color": _GREEN, "down_color": _RED, "opacity": 0.58}, + ) + ) + elif normalized_drawing == "ghost_feed": + layers.append( + xy.ghost_feed( + source="price", + anchor=(_future_date(7), last), + direction="up", + bars=24, + avg_hl_ticks=60.0, + variance_ticks=35.0, + tick_size=meta.tick_size, + seed=meta.seed + 900, + id="ghost-feed", + style={"up_color": _GREEN, "down_color": _RED, "opacity": 0.48}, + ) + ) + elif normalized_drawing == "xabcd": + indices = np.linspace(max(0, len(values) - 90), len(values) - 1, 5).round().astype(int) + points = [(str(values.dates[index]), float(values.close[index])) for index in indices] + layers.append( + xy.xabcd_pattern( + source="price", points=points, id="xabcd", style={"color": _VIOLET, "width": 1.5} + ) + ) + + active_tool = normalized_drawing if normalized_drawing != "none" else "crosshair" + return xy.finance_chart( + xy.candlestick( + values.dates, + values.open, + values.high, + values.low, + values.close, + volume=values.volume, + id="price", + name=f"{meta.symbol} OHLCV", + up_color=_GREEN, + down_color=_RED, + wick_color=_AMBER_DIM, + ), + *layers, + xy.x_axis(type_="time", tick_count=7), + xy.y_axis(label=f"{meta.currency} price", side="right", format=f".{meta.price_decimals}f"), + xy.legend(loc="upper left", ncols=3), + xy.finance_tools( + active=active_tool, + snap="ohlc", + selected="paper-risk" if active_tool.endswith("position") else None, + ), + title=f"{meta.symbol} · {resolution.upper()} · {range_key.upper()} · SIMULATED", + width="100%", + height=620, + style=_FINANCE_STYLE, + ) + + +def portfolio_performance_chart() -> Any: + series = data.portfolio_equity("1Y") + return xy.finance_chart( + xy.equity_drawdown( + x=series.dates, + equity=series.equity, + pane="drawdown", + mode="area", + id="portfolio-performance", + name="NAV", + style={"color": _AMBER, "fill_color": _AMBER, "drawdown_color": _RED}, + ), + xy.x_axis(type_="time", tick_count=6), + xy.y_axis(label="NAV (USD)", side="right"), + title="PORTFOLIO NAV + DRAWDOWN · SIMULATED", + width="100%", + height=410, + style=_FINANCE_STYLE, + ) + + +def portfolio_allocation_chart() -> xy.Chart: + labels, weights = data.portfolio_allocation() + colors = (_AMBER, _BLUE, _VIOLET, _GREEN, "#70d6ff", "#ff9f43", "#d5b3ff", _RED) + marks = [ + xy.bar([label], [float(weight)], name=label, color=colors[index % len(colors)], width=0.72) + for index, (label, weight) in enumerate(zip(labels, weights, strict=True)) + ] + return xy.bar_chart( + *marks, + xy.x_axis(label="security"), + xy.y_axis(label="weight (%)", side="right"), + xy.legend(show=False), + _theme(), + title="PORTFOLIO ALLOCATION", + width="100%", + height=285, + ) + + +def portfolio_contribution_chart() -> xy.Chart: + labels, contribution = data.portfolio_contribution() + marks = [ + xy.bar( + [label], [float(value)], name=label, color=_GREEN if value >= 0 else _RED, width=0.72 + ) + for label, value in zip(labels, contribution, strict=True) + ] + return xy.bar_chart( + *marks, + xy.hline(0.0, color=_AMBER_DIM, width=1.0), + xy.x_axis(label="security"), + xy.y_axis(label="unrealized P&L (USD)", side="right"), + xy.legend(show=False), + _theme(), + title="P&L CONTRIBUTION", + width="100%", + height=285, + ) + + +def portfolio_exposure_chart() -> xy.Chart: + labels, exposures = data.sector_exposures() + return xy.bar_chart( + xy.bar( + labels, exposures, orientation="horizontal", name="exposure", color=_AMBER, width=0.68 + ), + xy.x_axis(label="NAV exposure (%)"), + xy.y_axis(label="sector"), + xy.legend(show=False), + _theme(), + title="SECTOR EXPOSURE", + width="100%", + height=285, + ) + + +def _confidence(value: float | int | str) -> float: + if isinstance(value, str): + value = float(value.strip().rstrip("%")) + normalized = float(value) + if normalized > 1.0: + normalized /= 100.0 + if normalized not in {0.95, 0.99}: + raise ValueError("confidence must be 95% or 99%") + return normalized + + +def risk_distribution_chart(confidence: float | int | str = 0.95) -> Any: + normalized = _confidence(confidence) + returns = data.portfolio_returns("1Y") + return xy.finance_chart( + xy.returns_distribution( + returns, + bins=46, + confidence=normalized, + y="probability", + id="portfolio-returns", + style={"bar_color": _AMBER, "marker_color": _RED, "tail_color": "#71282b"}, + ), + xy.x_axis(label="daily return", format=".1%"), + xy.y_axis(label="probability", side="right", format=".1%"), + title=f"PORTFOLIO VaR / CVaR · {normalized:.0%} CONFIDENCE", + width="100%", + height=360, + style=_FINANCE_STYLE, + ) + + +def risk_correlation_chart() -> xy.Chart: + labels, matrix = data.correlation_matrix() + return xy.heatmap_chart( + xy.heatmap( + matrix, x=labels, y=labels, name="correlation", colormap="coolwarm", domain=(-1.0, 1.0) + ), + xy.x_axis(tick_label_angle=-34, tick_label_anchor="end"), + xy.y_axis(), + xy.colorbar(title="ρ"), + _theme(), + title="1Y RETURN CORRELATION", + width="100%", + height=360, + ) + + +def risk_factor_chart() -> xy.Chart: + labels, exposures = data.factor_exposures() + values = exposures * 100.0 + return xy.bar_chart( + xy.bar( + labels, values, orientation="horizontal", name="exposure", color=_VIOLET, width=0.68 + ), + xy.x_axis(label="exposure / beta × 100"), + xy.y_axis(label="factor"), + xy.legend(show=False), + _theme(), + title="FACTOR EXPOSURE", + width="100%", + height=320, + ) + + +def abbreviated_spec(chart: Any | None = None) -> dict[str, Any]: + """Return a deliberately small, JSON-safe chart/layer description.""" + + selected = chart or security_chart( + "AAPL", + range_key="6M", + overlays=("SMA 20", "VWAP"), + oscillator="RSI", + ) + if hasattr(selected, "build_payload"): + spec, _buffers = selected.build_payload() + else: + spec, _buffers = selected.figure().build_payload() + traces = [ + { + "kind": str(trace.get("kind", "")), + "name": str(trace.get("name") or ""), + } + for trace in spec.get("traces", []) + ] + layers = [] + for layer in spec.get("layers", []): + props = layer.get("props") or {} + materialized = ( + props.get("series") + or props.get("bars") + or props.get("profile") + or props.get("pattern") + or props.get("feed") + or {} + ) + layers.append( + { + "role": str(layer.get("role", "")), + "kind": str(layer.get("kind", "")), + "id": str(layer.get("id") or ""), + "pane": str(props.get("pane") or ""), + "rows": int(materialized.get("rows", 0)) + if isinstance(materialized, Mapping) + else 0, + } + ) + return { + "title": str(spec.get("title") or ""), + "trace_count": len(traces), + "traces": traces, + "layer_count": len(layers), + "layers": layers, + "x_axis": { + "label": str((spec.get("x_axis") or {}).get("label") or ""), + "type": str( + (spec.get("x_axis") or {}).get("kind") + or (spec.get("x_axis") or {}).get("type") + or "" + ), + }, + "y_axis": { + "label": str((spec.get("y_axis") or {}).get("label") or ""), + "type": str( + (spec.get("y_axis") or {}).get("kind") + or (spec.get("y_axis") or {}).get("type") + or "" + ), + }, + "tools": spec.get("tools") or {}, + } + + +MARKET_FOCUS_CHART = market_focus_chart() +MARKET_HEATMAP_CHART = market_heatmap_chart() +YIELD_CURVE_CHART = yield_curve_chart() + + +__all__ = [ + "MARKET_FOCUS_CHART", + "MARKET_HEATMAP_CHART", + "YIELD_CURVE_CHART", + "abbreviated_spec", + "market_focus_chart", + "market_heatmap_chart", + "market_pulse_chart", + "portfolio_allocation_chart", + "portfolio_contribution_chart", + "portfolio_exposure_chart", + "portfolio_performance_chart", + "risk_correlation_chart", + "risk_distribution_chart", + "risk_factor_chart", + "security_chart", + "ticket_metrics", + "yield_curve_chart", +] diff --git a/examples/reflex/xy_reflex_demo/components.py b/examples/reflex/xy_reflex_demo/components.py new file mode 100644 index 00000000..04a6eff6 --- /dev/null +++ b/examples/reflex/xy_reflex_demo/components.py @@ -0,0 +1,1412 @@ +"""Terminal shell and workspace components for the Reflex example.""" + +from __future__ import annotations + +import inspect +import json +from collections.abc import Mapping, Sequence +from typing import Any + +import reflex as rx + +import reflex_xy +from reflex_xy.tokens import BUILDER_ATTR + +from . import charts, data +from .state import ( + CONFIDENCE_LEVELS, + DRAWINGS, + OSCILLATORS, + OVERLAYS, + RANGES, + RESOLUTIONS, + TerminalState, +) + +INK = "#050505" +PANEL = "#0b0c0c" +PANEL_ALT = "#111313" +BORDER = "#34301f" +AMBER = "#ffb000" +AMBER_SOFT = "#d08d00" +GREEN = "#27d17f" +RED = "#ff5a5f" +CYAN = "#57c7ff" +TEXT = "#ece7d7" +MUTED = "#8e8a7b" +MONO = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" + +AS_OF = str(getattr(data, "AS_OF_DATE", getattr(data, "AS_OF", "2026-07-31"))) + +# The yield curve intentionally uses the kernel-served fixed-data tier while +# the market heatmap below is passed as a direct xy.Chart static payload. +YIELD_CURVE_TOKEN = reflex_xy.inline(charts.YIELD_CURVE_CHART) + + +def _get(obj: Any, *names: str, default: Any = "—") -> Any: + for name in names: + if isinstance(obj, Mapping) and name in obj: + return obj[name] + if hasattr(obj, name): + return getattr(obj, name) + return default + + +def _items(value: Any) -> list[tuple[str, Any]]: + if isinstance(value, Mapping): + return [(str(key), item) for key, item in value.items()] + return [] + + +def _float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _money(value: Any, *, decimals: int = 2) -> str: + number = _float(value) + sign = "-" if number < 0 else "" + return f"{sign}${abs(number):,.{decimals}f}" + + +def _percent(value: Any) -> str: + number = _float(value) + return f"{number:+.2f}%" + + +def _signed_color(value: Any) -> str: + return GREEN if _float(value) >= 0 else RED + + +def terminal_button( + label: Any, + *, + on_click: Any = None, + active: Any = False, + compact: bool = False, + **props: Any, +) -> rx.Component: + return rx.button( + label, + on_click=on_click, + variant="ghost", + radius="none", + min_height="25px" if compact else "30px", + padding="2px 7px" if compact else "4px 9px", + border=f"1px solid {AMBER}" if active is True else f"1px solid {BORDER}", + background=AMBER if active is True else PANEL_ALT, + color=INK if active is True else AMBER, + font_family=MONO, + font_size="10px" if compact else "11px", + font_weight="700", + letter_spacing="0.04em", + cursor="pointer", + _hover={"background": AMBER, "color": INK, "border_color": AMBER}, + _focus_visible={"outline": f"2px solid {CYAN}", "outline_offset": "2px"}, + **props, + ) + + +def state_button(label: str, value: Any, event: Any, *, compact: bool = True) -> rx.Component: + """A terminal button whose selected state is a Reflex boolean var.""" + return rx.button( + label, + on_click=event, + variant="ghost", + radius="none", + min_height="25px" if compact else "30px", + padding="2px 7px" if compact else "4px 9px", + border=f"1px solid {BORDER}", + background=rx.cond(value, AMBER, PANEL_ALT), + color=rx.cond(value, INK, AMBER), + font_family=MONO, + font_size="10px" if compact else "11px", + font_weight="700", + cursor="pointer", + _hover={"border_color": AMBER}, + _focus_visible={"outline": f"2px solid {CYAN}", "outline_offset": "2px"}, + ) + + +def panel( + title: Any, + *children: rx.Component, + subtitle: Any | None = None, + action: rx.Component | None = None, + **props: Any, +) -> rx.Component: + header_children: list[rx.Component] = [ + rx.text( + title, + color=AMBER, + font_family=MONO, + font_size="11px", + font_weight="800", + letter_spacing="0.08em", + text_transform="uppercase", + ) + ] + if subtitle is not None: + header_children.append( + rx.text(subtitle, color=MUTED, font_family=MONO, font_size="9px", margin_left="8px") + ) + props.setdefault("width", "100%") + return rx.box( + rx.hstack( + rx.hstack(*header_children, spacing="1", align="center"), + action or rx.box(), + justify="between", + align="center", + min_height="28px", + padding="4px 7px", + border_bottom=f"1px solid {BORDER}", + background="#16140c", + ), + rx.box(*children, padding="7px", width="100%"), + background=PANEL, + border=f"1px solid {BORDER}", + min_width="0", + overflow="hidden", + **props, + ) + + +def metric(label: str, value: Any, *, color: str = TEXT, note: Any = None) -> rx.Component: + return rx.box( + rx.text(label, color=MUTED, font_family=MONO, font_size="9px", letter_spacing="0.06em"), + rx.text(value, color=color, font_family=MONO, font_size="16px", font_weight="750"), + rx.text(note, color=MUTED, font_family=MONO, font_size="9px") + if note is not None + else rx.box(), + min_width="0", + ) + + +def terminal_select(options: Sequence[str], value: Any, on_change: Any, label: str) -> rx.Component: + return rx.vstack( + rx.text(label, color=MUTED, font_size="9px", font_family=MONO), + rx.select( + list(options), + value=value, + on_change=on_change, + size="1", + radius="none", + width="100%", + color_scheme="amber", + ), + spacing="1", + align="start", + min_width="110px", + ) + + +def terminal_input(label: str, value: Any, on_change: Any, **props: Any) -> rx.Component: + return rx.vstack( + rx.text(label, color=MUTED, font_family=MONO, font_size="9px"), + rx.input( + value=value, + on_change=on_change, + size="1", + radius="none", + background=INK, + border=f"1px solid {BORDER}", + color=TEXT, + font_family=MONO, + font_size="11px", + _focus={"border_color": CYAN, "box_shadow": f"0 0 0 1px {CYAN}"}, + **props, + ), + spacing="1", + align="start", + min_width="0", + ) + + +def command_bar() -> rx.Component: + return rx.vstack( + rx.hstack( + rx.text("XY", color=INK, background=AMBER, padding="3px 7px", font_weight="900"), + rx.text("COMMAND", color=AMBER, font_weight="800", font_size="10px"), + rx.input( + value=TerminalState.command, + on_change=TerminalState.set_command, + on_key_down=TerminalState.command_key, + placeholder="MKTS | DES AAPL | PORT | RISK | NEWS | HELP", + aria_label="Terminal command", + radius="none", + size="2", + background=INK, + border=f"1px solid {AMBER_SOFT}", + color=TEXT, + font_family=MONO, + font_size="12px", + flex="1", + _placeholder={"color": "#696555"}, + _focus={"border_color": CYAN, "box_shadow": f"0 0 0 1px {CYAN}"}, + ), + terminal_button("GO", on_click=TerminalState.execute_command, compact=False), + rx.text( + "SIMULATED DATA", + color=INK, + background=RED, + font_family=MONO, + font_size="9px", + font_weight="900", + padding="4px 7px", + white_space="nowrap", + ), + width="100%", + align="center", + spacing="2", + ), + rx.cond( + TerminalState.help_visible, + rx.text( + "MKTS Global markets DES Security PORT Portfolio " + "RISK Risk monitor NEWS Newswire", + color=CYAN, + font_family=MONO, + font_size="10px", + padding="4px 8px", + border=f"1px solid {CYAN}", + width="100%", + ), + rx.box(), + ), + spacing="1", + width="100%", + ) + + +def _quote_cell(row: Any) -> rx.Component: + symbol = str(_get(row, "symbol", "ticker")) + last = _get(row, "last", "price", "close", default=0.0) + change = _get( + row, + "change_percent", + "change_pct", + "percent_change", + "pct_change", + default=0.0, + ) + return rx.hstack( + rx.text(symbol, color=AMBER, font_weight="800"), + rx.text(f"{_float(last):,.2f}", color=TEXT), + rx.text(_percent(change), color=_signed_color(change)), + spacing="2", + align="center", + padding="2px 9px", + border_right=f"1px solid {BORDER}", + white_space="nowrap", + ) + + +def ticker_tape() -> rx.Component: + return rx.hstack( + rx.foreach(TerminalState.tape_quotes, _live_quote_cell), + width="100%", + overflow_x="auto", + spacing="0", + background="#080909", + border_top=f"1px solid {BORDER}", + border_bottom=f"1px solid {BORDER}", + font_family=MONO, + font_size="10px", + scrollbar_width="thin", + ) + + +def _live_quote_cell(row: rx.Var[dict[str, str]]) -> rx.Component: + return rx.hstack( + rx.text(row["symbol"], color=AMBER, font_weight="800"), + rx.text(row["last"], color=TEXT), + rx.text( + row["change"], + color=rx.cond(row["direction"] == "UP", GREEN, RED), + ), + spacing="2", + align="center", + padding="2px 9px", + border_right=f"1px solid {BORDER}", + white_space="nowrap", + ) + + +def watchlist() -> rx.Component: + rows = list(data.watchlist_rows()) + entries = [] + for row in rows: + symbol = str(_get(row, "symbol", "ticker")) + last = _get(row, "last", "price", "close", default=0.0) + change = _get( + row, + "change_percent", + "change_pct", + "percent_change", + "pct_change", + default=0.0, + ) + entries.append( + rx.button( + rx.grid( + rx.text(symbol, color=AMBER, font_weight="800"), + rx.text(f"{_float(last):,.2f}", color=TEXT, text_align="right"), + rx.text(_percent(change), color=_signed_color(change), text_align="right"), + columns="3", + width="100%", + align_items="center", + ), + on_click=TerminalState.select_symbol(symbol), + variant="ghost", + radius="none", + width="100%", + min_height="27px", + padding="3px 5px", + border_bottom="1px solid #1d1d19", + font_family=MONO, + font_size="10px", + _hover={"background": "#231c08"}, + _focus_visible={"outline": f"2px solid {CYAN}", "outline_offset": "-2px"}, + ) + ) + return panel( + "Watchlist", + rx.vstack(*entries, spacing="0", width="100%"), + subtitle=f"{len(entries)} securities", + padding="0", + height="100%", + ) + + +def _breadth_cards() -> rx.Component: + breadth = data.breadth_metrics() + cards = [] + for label, value in _items(breadth): + display = _percent(value) if "percent" in label else value + cards.append(metric(label.replace("_", " "), display)) + return rx.grid(*cards, columns=rx.breakpoints(initial="2", md="4"), gap="10px", width="100%") + + +def _movers_table() -> rx.Component: + rows = list(data.movers()) + body = [] + for row in rows: + symbol = str(_get(row, "symbol", "ticker")) + change = _get( + row, + "change_percent", + "change_pct", + "percent_change", + "pct_change", + default=0.0, + ) + body.append( + rx.grid( + rx.text(symbol, color=AMBER, font_weight="800"), + rx.text( + str(_get(row, "name", "label", default=symbol)), color=TEXT, overflow="hidden" + ), + rx.text(_percent(change), color=_signed_color(change), text_align="right"), + columns="3", + width="100%", + padding="4px 2px", + border_bottom="1px solid #1d1d19", + font_family=MONO, + font_size="10px", + ) + ) + return rx.vstack(*body, spacing="0", width="100%") + + +def markets_workspace() -> rx.Component: + return rx.vstack( + panel( + "Market focus · SPY", + reflex_xy.chart( + charts.MARKET_FOCUS_CHART, + height="520px", + id="market-finance-chart", + ), + subtitle="native FinanceChart · OHLCV · studies · tools", + action=terminal_button( + "DES SPY", + on_click=TerminalState.select_symbol("SPY"), + compact=True, + ), + ), + rx.grid( + panel( + "Market breadth", + _breadth_cards(), + grid_column=rx.breakpoints(initial="1", md="span 2"), + ), + panel("Top movers", _movers_table()), + columns=rx.breakpoints(initial="1", md="3"), + gap="8px", + width="100%", + ), + rx.grid( + panel( + "Cross-asset heatmap", + reflex_xy.chart(charts.MARKET_HEATMAP_CHART, height="300px", id="market-heatmap"), + subtitle="direct xy.Chart payload", + ), + panel( + "Yield curve", + reflex_xy.chart(YIELD_CURVE_TOKEN, height="300px", id="yield-curve"), + subtitle="inline() kernel token", + ), + columns=rx.breakpoints(initial="1", lg="2"), + gap="8px", + width="100%", + ), + panel( + "Live market pulse", + reflex_xy.chart(TerminalState.market_pulse, height="230px", id="market-pulse"), + subtitle="append-driven stream", + action=terminal_button( + rx.cond(TerminalState.streaming, "STOP", "GO LIVE"), + on_click=TerminalState.stream_quotes, + ), + ), + spacing="2", + width="100%", + ) + + +def _selected_instrument_card() -> rx.Component: + fallback: rx.Component = rx.box() + for symbol in reversed(data.instrument_symbols()): + instrument = data.instrument(symbol) + quote = data.quote(symbol) + content = rx.vstack( + rx.text( + str(_get(instrument, "name", "description", default=symbol)), color=TEXT, size="2" + ), + rx.grid( + metric("LAST", _money(quote.last), color=TEXT), + metric( + "DAY CHANGE", + _percent(quote.change_percent), + color=_signed_color(quote.change_percent), + ), + metric("VOLUME", f"{quote.volume:,.0f}"), + metric( + "ASSET", + str(_get(instrument, "asset_class", "kind", default="Security")), + ), + metric("SECTOR", str(_get(instrument, "sector", default="Global"))), + metric("BETA", f"{_float(_get(instrument, 'beta', default=0)):.2f}"), + metric("VENUE", str(_get(instrument, "exchange", "venue", default="Global"))), + metric("CCY", str(_get(instrument, "currency", default="USD"))), + columns=rx.breakpoints(initial="2", md="4"), + gap="8px", + width="100%", + ), + spacing="2", + width="100%", + ) + fallback = rx.cond(TerminalState.selected_symbol == str(symbol), content, fallback) + return fallback + + +def security_controls() -> rx.Component: + return panel( + "Security controls", + rx.vstack( + rx.flex( + terminal_select( + list(data.instrument_symbols()), + TerminalState.selected_symbol, + TerminalState.select_symbol, + "SYMBOL", + ), + rx.vstack( + rx.text("RANGE", color=MUTED, font_size="9px", font_family=MONO), + rx.hstack( + *[ + state_button( + value, + TerminalState.range_key == value, + TerminalState.set_range_key(value), + ) + for value in RANGES + ], + spacing="1", + wrap="wrap", + ), + spacing="1", + align="start", + ), + rx.vstack( + rx.text("RESOLUTION", color=MUTED, font_size="9px", font_family=MONO), + rx.hstack( + *[ + state_button( + value, + TerminalState.resolution == value, + TerminalState.set_resolution(value), + ) + for value in RESOLUTIONS + ], + spacing="1", + ), + spacing="1", + align="start", + ), + gap="10px", + width="100%", + wrap="wrap", + align="end", + ), + rx.vstack( + rx.text("OVERLAYS", color=MUTED, font_size="9px", font_family=MONO), + rx.hstack( + *[ + state_button( + overlay, + TerminalState.overlays.contains(overlay), + TerminalState.toggle_overlay(overlay), + ) + for overlay in OVERLAYS + ], + spacing="1", + wrap="wrap", + ), + spacing="1", + align="start", + ), + rx.flex( + terminal_select( + OSCILLATORS, + TerminalState.oscillator, + TerminalState.set_oscillator, + "OSCILLATOR", + ), + terminal_select( + DRAWINGS, TerminalState.drawing, TerminalState.set_drawing, "DRAWING PRESET" + ), + gap="10px", + width="100%", + wrap="wrap", + ), + spacing="2", + width="100%", + ), + ) + + +def paper_ticket() -> rx.Component: + return panel( + "Paper risk ticket", + rx.vstack( + rx.grid( + terminal_select( + ("Long", "Short"), + TerminalState.ticket_side, + TerminalState.set_ticket_side, + "SIDE", + ), + terminal_input("ENTRY", TerminalState.ticket_entry, TerminalState.set_ticket_entry), + terminal_input("STOP", TerminalState.ticket_stop, TerminalState.set_ticket_stop), + terminal_input( + "TARGET", TerminalState.ticket_target, TerminalState.set_ticket_target + ), + columns=rx.breakpoints(initial="2", md="4"), + gap="7px", + width="100%", + ), + rx.grid( + terminal_input( + "ACCOUNT SIZE", TerminalState.ticket_account, TerminalState.set_ticket_account + ), + terminal_input("RISK %", TerminalState.ticket_risk, TerminalState.set_ticket_risk), + metric("RISK AMOUNT", TerminalState.ticket_risk_amount), + metric("POSITION SIZE", TerminalState.ticket_position_size), + metric("REWARD / RISK", TerminalState.ticket_reward_risk), + columns=rx.breakpoints(initial="2", md="5"), + gap="8px", + width="100%", + ), + rx.cond( + TerminalState.ticket_valid, + rx.text( + "VALID PAPER SCENARIO — NO ORDER WILL BE SUBMITTED", + color=GREEN, + font_family=MONO, + font_size="9px", + ), + rx.text(TerminalState.ticket_error, color=RED, font_family=MONO, font_size="9px"), + ), + spacing="2", + width="100%", + ), + subtitle="simulation only", + ) + + +def _story_teasers(stories: Sequence[Any]) -> rx.Component: + return rx.vstack( + *[ + rx.box( + rx.text( + str(_get(story, "timestamp", "time", default="--:--")), + color=CYAN, + font_size="9px", + ), + rx.text(str(_get(story, "headline", "title")), color=TEXT, font_size="10px"), + padding="5px 0", + border_bottom="1px solid #1d1d19", + width="100%", + ) + for story in stories[:4] + ], + spacing="0", + width="100%", + font_family=MONO, + ) + + +def _related_news() -> rx.Component: + result: rx.Component = rx.text("NO RELATED STORIES", color=MUTED, font_size="9px") + for symbol in reversed(data.instrument_symbols()): + stories = data.stories(symbol) + content = _story_teasers(stories) if stories else result + result = rx.cond(TerminalState.selected_symbol == symbol, content, result) + return result + + +def security_workspace() -> rx.Component: + return rx.vstack( + security_controls(), + panel( + rx.hstack( + rx.text(TerminalState.selected_symbol, color=AMBER, font_weight="900"), + rx.text("OHLCV ANALYSIS", color=TEXT), + spacing="2", + ), + reflex_xy.chart( + TerminalState.security_figure, + on_hover=TerminalState.on_chart_hover, + on_view_change=TerminalState.on_chart_view, + height="610px", + id="security-chart", + ), + subtitle=TerminalState.view_status, + ), + rx.grid( + panel("Instrument", _selected_instrument_card()), + panel("Related news", _related_news()), + columns=rx.breakpoints(initial="1", md="2"), + gap="8px", + width="100%", + ), + paper_ticket(), + spacing="2", + width="100%", + ) + + +def _summary_metrics() -> rx.Component: + summary = data.portfolio_summary() + items = _items(summary) + return rx.grid( + *[ + metric( + label.replace("_", " "), + ( + f"{_float(value):.2f}%" + if "percent" in label.lower() + else _money(value) + if any(key in label.lower() for key in ("nav", "pnl", "value", "cash", "cost")) + else value + ), + color=_signed_color(value) if "pnl" in label.lower() else TEXT, + ) + for label, value in items + ], + columns=rx.breakpoints(initial="2", md="4"), + gap="12px", + width="100%", + ) + + +def positions_table() -> rx.Component: + rows = [] + for position in data.position_rows(): + symbol = str(_get(position, "symbol", "ticker")) + pnl = _get(position, "pnl", "unrealized_pnl", "profit_loss", default=0.0) + rows.append( + rx.button( + rx.grid( + rx.text(symbol, color=AMBER, font_weight="900"), + rx.text( + f"{_float(_get(position, 'quantity', 'units', default=0)):,.2f}", + text_align="right", + ), + rx.text( + _money(_get(position, "market_value", "value", default=0)), + text_align="right", + ), + rx.text(_money(pnl), color=_signed_color(pnl), text_align="right"), + rx.text( + _percent( + _get( + position, + "pnl_percent", + "pnl_pct", + "return_pct", + default=0, + ) + ), + color=_signed_color(pnl), + text_align="right", + ), + columns="5", + width="100%", + ), + on_click=TerminalState.drilldown_position(symbol), + variant="ghost", + radius="none", + min_height="30px", + padding="4px 3px", + width="100%", + color=TEXT, + font_family=MONO, + font_size="10px", + border_bottom="1px solid #1d1d19", + _hover={"background": "#231c08"}, + ) + ) + return rx.vstack( + rx.grid( + *[ + rx.text(label, color=MUTED, text_align="right" if index else "left") + for index, label in enumerate(("SYMBOL", "QTY", "MKT VALUE", "P&L", "RETURN")) + ], + columns="5", + width="100%", + padding="3px", + font_family=MONO, + font_size="9px", + ), + *rows, + spacing="0", + width="100%", + overflow_x="auto", + ) + + +def portfolio_workspace() -> rx.Component: + return rx.vstack( + panel("Portfolio summary", _summary_metrics(), subtitle="fictional multi-asset book"), + panel( + "NAV & drawdown", + reflex_xy.chart( + TerminalState.portfolio_figure, height="330px", id="portfolio-performance" + ), + subtitle="@reflex_xy.figure", + ), + panel("Positions", positions_table(), subtitle="select a row for DES"), + rx.grid( + panel( + "Allocation", + reflex_xy.chart( + charts.portfolio_allocation_chart(), height="245px", id="portfolio-allocation" + ), + ), + panel( + "Contribution", + reflex_xy.chart( + charts.portfolio_contribution_chart(), + height="245px", + id="portfolio-contribution", + ), + ), + panel( + "Exposure", + reflex_xy.chart( + charts.portfolio_exposure_chart(), height="245px", id="portfolio-exposure" + ), + ), + columns=rx.breakpoints(initial="1", md="3"), + gap="8px", + width="100%", + ), + spacing="2", + width="100%", + ) + + +def scenario_table(confidence: float = 0.95) -> rx.Component: + scenarios = list(data.stress_scenarios(confidence)) + rows = [] + for scenario in scenarios: + name = str(_get(scenario, "name", "scenario", "label")) + impact = _get(scenario, "impact", "pnl", "portfolio_impact", default=0.0) + rows.append( + rx.button( + rx.grid( + rx.text(name, color=AMBER, font_weight="800"), + rx.text( + str(_get(scenario, "shock", "description", default="Deterministic shock")), + color=MUTED, + ), + rx.text(_money(impact), color=_signed_color(impact), text_align="right"), + columns="3", + width="100%", + ), + on_click=TerminalState.set_scenario(name), + variant="ghost", + radius="none", + width="100%", + min_height="30px", + color=TEXT, + font_family=MONO, + font_size="10px", + border_bottom="1px solid #1d1d19", + background=rx.cond( + TerminalState.selected_scenario == name, "#231c08", "transparent" + ), + _hover={"background": "#231c08"}, + ) + ) + return rx.vstack(*rows, spacing="0", width="100%") + + +def risk_workspace() -> rx.Component: + return rx.vstack( + panel( + "Risk controls", + rx.hstack( + rx.text("CONFIDENCE", color=MUTED, font_family=MONO, font_size="9px"), + *[ + state_button( + confidence, + TerminalState.confidence_label == confidence, + TerminalState.set_confidence(confidence), + ) + for confidence in CONFIDENCE_LEVELS + ], + spacing="1", + align="center", + ), + ), + rx.grid( + panel( + "Historical VaR / CVaR", + reflex_xy.chart(TerminalState.risk_figure, height="310px", id="risk-distribution"), + subtitle=TerminalState.confidence_label, + ), + panel( + "Correlation matrix", + reflex_xy.chart( + charts.risk_correlation_chart(), height="310px", id="risk-correlation" + ), + ), + columns=rx.breakpoints(initial="1", lg="2"), + gap="8px", + width="100%", + ), + rx.grid( + panel( + "Factor exposure", + reflex_xy.chart(charts.risk_factor_chart(), height="265px", id="risk-factors"), + ), + panel( + "Stress scenarios", + rx.cond( + TerminalState.confidence_label == "99%", + scenario_table(0.99), + scenario_table(0.95), + ), + subtitle="select scenario", + ), + columns=rx.breakpoints(initial="1", lg="2"), + gap="8px", + width="100%", + ), + spacing="2", + width="100%", + ) + + +def _story_id(story: Any) -> str: + return str(_get(story, "id", "story_id", default="N001")) + + +def _story_detail(story: Any) -> rx.Component: + sentiment = _get(story, "sentiment", default="Neutral") + impact = _get(story, "impact", "importance", default="Medium") + return rx.vstack( + rx.hstack( + rx.text(str(_get(story, "source", default="XY NEWS")), color=CYAN), + rx.text(str(_get(story, "timestamp", "time", default="--:--")), color=MUTED), + spacing="2", + ), + rx.heading(str(_get(story, "headline", "title")), color=TEXT, size="4", font_family=MONO), + rx.hstack( + rx.text( + f"SENTIMENT {sentiment}", + color=GREEN + if str(sentiment).lower() == "positive" + else RED + if str(sentiment).lower() == "negative" + else AMBER, + ), + rx.text(f"IMPACT {impact}", color=AMBER), + spacing="3", + font_size="10px", + ), + rx.text( + str(_get(story, "body", "summary", "description", default="Simulated market story.")), + color=TEXT, + font_family=MONO, + font_size="12px", + line_height="1.6", + ), + spacing="3", + align="start", + width="100%", + ) + + +def news_list() -> rx.Component: + rows = [] + for story in data.stories(): + story_id = _story_id(story) + sentiment = str(_get(story, "sentiment", default="Neutral")) + rows.append( + rx.button( + rx.grid( + rx.text(str(_get(story, "timestamp", "time", default="--:--")), color=CYAN), + rx.text(str(_get(story, "headline", "title")), color=TEXT, text_align="left"), + rx.text( + sentiment[:3].upper(), + color=GREEN + if sentiment.lower() == "positive" + else RED + if sentiment.lower() == "negative" + else AMBER, + text_align="right", + ), + columns="3", + width="100%", + align_items="start", + ), + on_click=TerminalState.select_story(story_id), + variant="ghost", + radius="none", + width="100%", + height="auto", + min_height="42px", + padding="5px 3px", + border_bottom="1px solid #1d1d19", + background=rx.cond( + TerminalState.selected_story == story_id, "#231c08", "transparent" + ), + font_family=MONO, + font_size="10px", + white_space="normal", + _hover={"background": "#231c08"}, + ) + ) + return rx.vstack(*rows, spacing="0", width="100%") + + +def selected_story_detail() -> rx.Component: + stories = list(data.stories()) + if not stories: + return rx.text("NO STORIES", color=MUTED) + result = _story_detail(stories[0]) + for story in reversed(stories): + result = rx.cond( + TerminalState.selected_story == _story_id(story), _story_detail(story), result + ) + return result + + +def calendar_table() -> rx.Component: + rows = [] + for event in data.calendar_events(): + importance = str(_get(event, "importance", "impact", default="Medium")) + rows.append( + rx.grid( + rx.text(str(_get(event, "time", "timestamp", default="--:--")), color=CYAN), + rx.text(str(_get(event, "country", "region", default="US")), color=AMBER), + rx.text(str(_get(event, "event", "name", "title")), color=TEXT), + rx.text(importance, color=RED if importance.lower() == "high" else AMBER), + rx.text(str(_get(event, "consensus", "forecast", default="—")), text_align="right"), + rx.text(str(_get(event, "prior", "previous", default="—")), text_align="right"), + columns="6", + width="100%", + padding="5px 2px", + border_bottom="1px solid #1d1d19", + font_family=MONO, + font_size="10px", + ) + ) + return rx.vstack(*rows, spacing="0", width="100%", overflow_x="auto") + + +def news_workspace() -> rx.Component: + return rx.vstack( + rx.grid( + panel("Newswire", news_list(), subtitle="fictional headlines"), + panel("Story detail", selected_story_detail()), + columns=rx.breakpoints(initial="1", lg="2"), + gap="8px", + width="100%", + ), + panel("Economic calendar", calendar_table(), subtitle=f"as of {AS_OF}"), + spacing="2", + width="100%", + ) + + +def workspace() -> rx.Component: + return rx.box( + rx.cond( + TerminalState.workspace == "MARKETS", + markets_workspace(), + rx.cond( + TerminalState.workspace == "SECURITY", + security_workspace(), + rx.cond( + TerminalState.workspace == "PORTFOLIO", + portfolio_workspace(), + rx.cond(TerminalState.workspace == "RISK", risk_workspace(), news_workspace()), + ), + ), + ), + width="100%", + min_width="0", + ) + + +def context_rail() -> rx.Component: + return rx.vstack( + panel( + "Context", + rx.vstack( + rx.text(TerminalState.workspace, color=AMBER, font_family=MONO, font_weight="900"), + rx.text( + f"DES {TerminalState.selected_symbol}", + color=TEXT, + font_family=MONO, + font_size="11px", + ), + rx.text(TerminalState.view_status, color=MUTED, font_family=MONO, font_size="9px"), + spacing="1", + align="start", + ), + ), + panel( + "Chart readout", + rx.cond( + TerminalState.hovered.length() > 0, + rx.vstack( + rx.text( + f"X {TerminalState.hovered['x']}", + color=TEXT, + font_family=MONO, + font_size="10px", + ), + rx.text( + f"Y {TerminalState.hovered['y']}", + color=TEXT, + font_family=MONO, + font_size="10px", + ), + spacing="1", + align="start", + ), + rx.text("HOVER A CHART POINT", color=MUTED, font_family=MONO, font_size="9px"), + ), + ), + panel( + "Quick functions", + rx.vstack( + terminal_button( + "DES AAPL", on_click=TerminalState.select_symbol("AAPL"), width="100%" + ), + terminal_button( + "PORTFOLIO", on_click=TerminalState.choose_workspace("PORTFOLIO"), width="100%" + ), + terminal_button( + "RISK MONITOR", on_click=TerminalState.choose_workspace("RISK"), width="100%" + ), + terminal_button( + "NEWSWIRE", on_click=TerminalState.choose_workspace("NEWS"), width="100%" + ), + spacing="1", + width="100%", + ), + ), + panel( + "System", + rx.vstack( + rx.hstack( + rx.text("DATA"), + rx.text("SIMULATED", color=RED), + justify="between", + width="100%", + ), + rx.hstack( + rx.text("AS OF"), rx.text(AS_OF, color=TEXT), justify="between", width="100%" + ), + rx.hstack( + rx.text("STREAM"), + rx.text( + rx.cond(TerminalState.streaming, "LIVE", "IDLE"), + color=rx.cond(TerminalState.streaming, GREEN, MUTED), + ), + justify="between", + width="100%", + ), + terminal_button( + rx.cond(TerminalState.streaming, "STOP LIVE TAPE", "START LIVE TAPE"), + on_click=TerminalState.stream_quotes, + width="100%", + ), + spacing="1", + width="100%", + color=MUTED, + font_family=MONO, + font_size="9px", + ), + ), + width="100%", + spacing="2", + ) + + +def function_keys() -> rx.Component: + keys = ( + ("F1", "MARKETS"), + ("F2", "SECURITY"), + ("F3", "PORTFOLIO"), + ("F4", "RISK"), + ("F5", "NEWS"), + ) + return rx.hstack( + *[ + rx.button( + rx.hstack( + rx.text(key, color=INK, background=AMBER, padding="2px 4px", font_weight="900"), + rx.text(label, color=TEXT), + spacing="1", + ), + on_click=TerminalState.choose_workspace(label), + variant="ghost", + radius="none", + min_height="27px", + padding="2px 6px", + border_right=f"1px solid {BORDER}", + font_family=MONO, + font_size="9px", + _hover={"background": "#231c08"}, + _focus_visible={"outline": f"2px solid {CYAN}", "outline_offset": "-2px"}, + ) + for key, label in keys + ], + terminal_button("DEV", on_click=TerminalState.toggle_developer, compact=True), + width="100%", + overflow_x="auto", + spacing="0", + background="#090a0a", + border_top=f"1px solid {BORDER}", + border_bottom=f"1px solid {BORDER}", + ) + + +def _source(obj: Any) -> str: + fget = getattr(obj, "_fget", None) + if fget is not None: + builder = getattr(fget, BUILDER_ATTR, None) + return inspect.getsource(builder if builder is not None else fget) + handler = getattr(obj, "fn", None) + return inspect.getsource(handler if handler is not None else obj) + + +def developer_drawer() -> rx.Component: + source = "\n\n".join( + _source(obj) + for obj in ( + TerminalState.security_figure, + TerminalState.risk_figure, + TerminalState.stream_quotes, + ) + ) + try: + spec = json.dumps( + charts.abbreviated_spec( + charts.security_chart( + "AAPL", + range_key="3M", + overlays=("SMA 20", "VWAP"), + oscillator="RSI", + ) + ), + indent=2, + default=str, + ) + except (TypeError, ValueError): + spec = "chart spec unavailable" + return rx.cond( + TerminalState.developer_open, + rx.fragment( + rx.box( + on_click=TerminalState.toggle_developer, + position="fixed", + inset="0", + background="rgba(0,0,0,0.68)", + z_index="60", + ), + rx.box( + rx.hstack( + rx.text("DEVELOPER CONSOLE", color=AMBER, font_family=MONO, font_weight="900"), + terminal_button("CLOSE", on_click=TerminalState.toggle_developer), + justify="between", + width="100%", + padding="8px", + border_bottom=f"1px solid {BORDER}", + ), + rx.hstack( + *[ + state_button( + tab, + TerminalState.developer_tab == tab, + TerminalState.set_developer_tab(tab), + ) + for tab in ("SOURCE", "STATE", "SPEC") + ], + padding="8px", + spacing="1", + ), + rx.box( + rx.cond( + TerminalState.developer_tab == "SOURCE", + rx.el.pre(source), + rx.cond( + TerminalState.developer_tab == "STATE", + rx.el.pre(TerminalState.state_snapshot), + rx.el.pre(spec), + ), + ), + padding="10px", + color="#d9f99d", + font_family=MONO, + font_size="10px", + line_height="1.45", + white_space="pre-wrap", + overflow="auto", + flex="1", + ), + position="fixed", + right="0", + top="0", + bottom="0", + width=rx.breakpoints(initial="100%", md="min(640px, 72vw)"), + background=PANEL, + border_left=f"1px solid {AMBER}", + z_index="70", + display="flex", + flex_direction="column", + ), + ), + rx.box(), + ) + + +def terminal_shell() -> rx.Component: + return rx.box( + rx.box( + rx.hstack( + rx.vstack( + rx.hstack( + rx.text( + "XY TERMINAL", + color=AMBER, + font_family=MONO, + font_size="18px", + font_weight="950", + ), + rx.text( + "MULTI-ASSET ANALYTICS", color=MUTED, font_family=MONO, font_size="9px" + ), + spacing="2", + align="center", + ), + rx.text( + "INDEPENDENT TERMINAL-STYLE DEMO · NO EXTERNAL MARKET FEED", + color=MUTED, + font_family=MONO, + font_size="8px", + ), + spacing="0", + align="start", + ), + rx.spacer(), + rx.text(f"AS OF {AS_OF}", color=TEXT, font_family=MONO, font_size="9px"), + align="center", + width="100%", + padding="6px 8px", + ), + command_bar(), + padding="0 8px 7px", + background="#090a0a", + ), + ticker_tape(), + rx.grid( + rx.box(watchlist(), min_width="0"), + rx.box(workspace(), min_width="0", overflow="hidden"), + rx.box(context_rail(), min_width="0"), + grid_template_columns=rx.breakpoints( + initial="minmax(0, 1fr)", + lg="210px minmax(0, 1fr) 225px", + ), + gap="8px", + width="100%", + padding="8px", + align_items="start", + ), + function_keys(), + rx.hstack( + rx.text(TerminalState.command_status, color=CYAN), + rx.spacer(), + rx.text("● LOCAL", color=GREEN), + rx.text("NO API KEY", color=MUTED), + width="100%", + padding="3px 8px", + background="#080909", + font_family=MONO, + font_size="9px", + ), + developer_drawer(), + background=INK, + color=TEXT, + min_height="100vh", + width="100%", + font_family=MONO, + ) + + +def index() -> rx.Component: + return terminal_shell() + + +__all__ = [ + "YIELD_CURVE_TOKEN", + "context_rail", + "developer_drawer", + "index", + "markets_workspace", + "news_workspace", + "portfolio_workspace", + "risk_workspace", + "security_workspace", + "terminal_shell", +] diff --git a/examples/reflex/xy_reflex_demo/data.py b/examples/reflex/xy_reflex_demo/data.py new file mode 100644 index 00000000..0d9f6ff9 --- /dev/null +++ b/examples/reflex/xy_reflex_demo/data.py @@ -0,0 +1,927 @@ +"""Deterministic simulated market data for the XY terminal example. + +Nothing in this module reaches the network. Every quote, story, calendar +entry, portfolio value, and risk result is reproducible from the fixed +``AS_OF`` date and seeds below. Large NumPy columns live behind module-level +caches rather than in Reflex state. +""" + +from __future__ import annotations + +import math +from collections.abc import Mapping, Sequence +from dataclasses import asdict, dataclass +from datetime import date +from functools import cache, lru_cache +from typing import Any + +import numpy as np + +AS_OF = date(2026, 7, 31) +SIMULATED_DATA_LABEL = f"SIMULATED DATA · AS OF {AS_OF.isoformat()}" + +_AS_OF64 = np.datetime64(AS_OF.isoformat(), "D") +_START64 = np.datetime64("2023-08-01", "D") +_RANGE_DAYS = {"1M": 31, "3M": 92, "6M": 183, "1Y": 366} +_RESOLUTIONS = frozenset({"1D", "1W"}) +_RANGES = frozenset((*_RANGE_DAYS, "MAX")) + + +def _readonly(values: Any, *, dtype: Any = np.float64) -> np.ndarray: + array = np.ascontiguousarray(values, dtype=dtype) + array.setflags(write=False) + return array + + +@dataclass(frozen=True, slots=True) +class Instrument: + """Metadata and simulation parameters for a terminal security.""" + + symbol: str + name: str + asset_class: str + sector: str + currency: str + exchange: str + tick_size: float + price_decimals: int + base_price: float + annual_drift: float + annual_volatility: float + beta: float + base_volume: float + seed: int + + +@dataclass(frozen=True, slots=True) +class Quote: + symbol: str + name: str + asset_class: str + last: float + change: float + change_percent: float + open: float + high: float + low: float + volume: float + as_of: str = AS_OF.isoformat() + + +@dataclass(frozen=True, slots=True) +class Position: + symbol: str + quantity: float + average_cost: float + account: str = "SIM-PRIMARY" + + +@dataclass(frozen=True, slots=True) +class NewsItem: + id: str + timestamp: str + source: str + headline: str + summary: str + symbols: tuple[str, ...] + sentiment: str + impact: str + + +@dataclass(frozen=True, slots=True) +class CalendarEvent: + id: str + timestamp: str + country: str + event: str + importance: str + actual: str + forecast: str + previous: str + + +@dataclass(frozen=True, slots=True) +class OHLCV: + """Read-only aligned OHLCV columns.""" + + dates: np.ndarray + open: np.ndarray + high: np.ndarray + low: np.ndarray + close: np.ndarray + volume: np.ndarray + symbol: str + resolution: str + range_key: str + + def __post_init__(self) -> None: + dates = _readonly(self.dates, dtype="datetime64[D]") + columns = tuple( + _readonly(getattr(self, name)) for name in ("open", "high", "low", "close", "volume") + ) + size = len(dates) + if any(column.ndim != 1 or len(column) != size for column in columns): + raise ValueError("OHLCV columns must be aligned one-dimensional arrays") + if size and not all(np.isfinite(column).all() for column in columns): + raise ValueError("OHLCV columns must contain only finite values") + open_, high, low, close, volume = columns + if np.any(high < np.maximum(open_, close)) or np.any(low > np.minimum(open_, close)): + raise ValueError("OHLCV high/low invariants are violated") + if np.any(low <= 0) or np.any(volume < 0): + raise ValueError("OHLCV prices must be positive and volume non-negative") + object.__setattr__(self, "dates", dates) + for name, column in zip(("open", "high", "low", "close", "volume"), columns, strict=True): + object.__setattr__(self, name, column) + + @property + def x(self) -> np.ndarray: + return self.dates + + def __len__(self) -> int: + return len(self.dates) + + +@dataclass(frozen=True, slots=True) +class PortfolioSeries: + dates: np.ndarray + equity: np.ndarray + returns: np.ndarray + pnl: np.ndarray + + def __post_init__(self) -> None: + dates = _readonly(self.dates, dtype="datetime64[D]") + equity = _readonly(self.equity) + returns = _readonly(self.returns) + pnl = _readonly(self.pnl) + if ( + len(dates) != len(equity) + or len(pnl) != len(equity) + or len(returns) != max(0, len(equity) - 1) + ): + raise ValueError("portfolio series columns are not aligned") + if ( + not np.isfinite(equity).all() + or not np.isfinite(returns).all() + or not np.isfinite(pnl).all() + ): + raise ValueError("portfolio series must contain only finite values") + object.__setattr__(self, "dates", dates) + object.__setattr__(self, "equity", equity) + object.__setattr__(self, "returns", returns) + object.__setattr__(self, "pnl", pnl) + + +@dataclass(frozen=True, slots=True) +class ScenarioResult: + name: str + description: str + confidence: float + pnl: float + loss_percent: float + nav_after: float + + +INSTRUMENTS: Mapping[str, Instrument] = { + item.symbol: item + for item in ( + Instrument( + "SPY", + "S&P 500 ETF", + "Equity ETF", + "Broad Market", + "USD", + "ARCX", + 0.01, + 2, + 420.0, + 0.085, + 0.17, + 1.00, + 74_000_000, + 101, + ), + Instrument( + "AAPL", + "Apple Inc.", + "Equity", + "Technology", + "USD", + "XNAS", + 0.01, + 2, + 155.0, + 0.10, + 0.25, + 1.18, + 58_000_000, + 103, + ), + Instrument( + "MSFT", + "Microsoft Corp.", + "Equity", + "Technology", + "USD", + "XNAS", + 0.01, + 2, + 310.0, + 0.11, + 0.23, + 1.08, + 24_000_000, + 107, + ), + Instrument( + "NVDA", + "NVIDIA Corp.", + "Equity", + "Technology", + "USD", + "XNAS", + 0.01, + 2, + 44.0, + 0.18, + 0.48, + 1.62, + 310_000_000, + 109, + ), + Instrument( + "JPM", + "JPMorgan Chase", + "Equity", + "Financials", + "USD", + "XNYS", + 0.01, + 2, + 145.0, + 0.08, + 0.24, + 1.10, + 9_500_000, + 113, + ), + Instrument( + "XOM", + "Exxon Mobil", + "Equity", + "Energy", + "USD", + "XNYS", + 0.01, + 2, + 102.0, + 0.055, + 0.25, + 0.88, + 17_000_000, + 127, + ), + Instrument( + "EURUSD", + "Euro / U.S. Dollar", + "FX", + "G10 FX", + "USD", + "OTC", + 0.0001, + 4, + 1.09, + 0.002, + 0.085, + 0.08, + 5_200_000_000, + 131, + ), + Instrument( + "USDJPY", + "U.S. Dollar / Yen", + "FX", + "G10 FX", + "JPY", + "OTC", + 0.01, + 2, + 142.0, + 0.005, + 0.10, + 0.12, + 4_600_000_000, + 137, + ), + Instrument( + "XAUUSD", + "Gold Spot / U.S. Dollar", + "Commodity", + "Metals", + "USD", + "OTC", + 0.10, + 1, + 1_940.0, + 0.06, + 0.18, + 0.18, + 185_000, + 139, + ), + Instrument( + "BTCUSD", + "Bitcoin / U.S. Dollar", + "Crypto", + "Digital Assets", + "USD", + "24/7", + 0.10, + 1, + 29_000.0, + 0.20, + 0.62, + 1.25, + 31_000, + 149, + ), + ) +} + +_SYMBOLS = tuple(INSTRUMENTS) +_WATCHLIST = _SYMBOLS +_PORTFOLIO_CASH = 175_000.0 +_POSITIONS = ( + Position("SPY", 180.0, 468.20), + Position("AAPL", 240.0, 181.35), + Position("MSFT", 120.0, 374.60), + Position("NVDA", 480.0, 92.80), + Position("JPM", 190.0, 188.10), + Position("XOM", 260.0, 109.40), + Position("XAUUSD", 16.0, 2_180.0), + Position("BTCUSD", 0.75, 54_500.0), +) + + +def instrument_symbols() -> tuple[str, ...]: + return _SYMBOLS + + +def instrument(symbol: str) -> Instrument: + key = symbol.upper().replace("/", "").strip() + try: + return INSTRUMENTS[key] + except KeyError as exc: + raise ValueError(f"unknown simulated instrument {symbol!r}") from exc + + +@lru_cache(maxsize=1) +def _business_dates() -> np.ndarray: + dates = np.arange(_START64, _AS_OF64 + np.timedelta64(1, "D"), dtype="datetime64[D]") + dates = dates[np.is_busday(dates)] + return _readonly(dates, dtype="datetime64[D]") + + +@lru_cache(maxsize=1) +def _market_factors() -> tuple[np.ndarray, Mapping[str, np.ndarray]]: + n = len(_business_dates()) + rng = np.random.default_rng(20260802) + market = rng.standard_normal(n) + class_seeds = {"Equity ETF": 211, "Equity": 223, "FX": 227, "Commodity": 229, "Crypto": 233} + factors = { + name: np.random.default_rng(seed).standard_normal(n) for name, seed in class_seeds.items() + } + return _readonly(market), {name: _readonly(values) for name, values in factors.items()} + + +@cache +def _daily_history(symbol: str) -> OHLCV: + meta = instrument(symbol) + dates = _business_dates() + market, class_factors = _market_factors() + rng = np.random.default_rng(meta.seed) + idiosyncratic = rng.standard_normal(len(dates)) + raw = meta.beta * 0.46 * market + 0.34 * class_factors[meta.asset_class] + 0.72 * idiosyncratic + raw = (raw - float(np.mean(raw))) / float(np.std(raw)) + daily_sigma = meta.annual_volatility / math.sqrt(252.0) + log_returns = meta.annual_drift / 252.0 - 0.5 * daily_sigma**2 + daily_sigma * raw + close = meta.base_price * np.exp(np.cumsum(log_returns)) + + previous = np.concatenate(([meta.base_price], close[:-1])) + overnight = rng.normal(0.0, daily_sigma * 0.20, len(dates)) + open_ = previous * np.exp(overnight) + spread = np.maximum( + np.abs(rng.normal(daily_sigma * 0.48, daily_sigma * 0.16, len(dates))), + daily_sigma * 0.08, + ) + high = np.maximum(open_, close) * (1.0 + spread) + low = np.minimum(open_, close) * np.maximum( + 0.02, 1.0 - spread * rng.uniform(0.72, 1.12, len(dates)) + ) + volume = meta.base_volume * rng.lognormal(mean=-0.08, sigma=0.33, size=len(dates)) + volume *= 1.0 + np.minimum(np.abs(log_returns) / max(daily_sigma, 1e-12), 4.0) * 0.15 + return OHLCV(dates, open_, high, low, close, volume, meta.symbol, "1D", "MAX") + + +def _weekly(daily: OHLCV) -> OHLCV: + python_dates = daily.dates.astype(object) + week_keys = np.fromiter( + (value.isocalendar().year * 100 + value.isocalendar().week for value in python_dates), + dtype=np.int64, + count=len(python_dates), + ) + starts = np.flatnonzero(np.r_[True, week_keys[1:] != week_keys[:-1]]) + ends = np.r_[starts[1:], len(week_keys)] + return OHLCV( + daily.dates[ends - 1], + daily.open[starts], + np.asarray( + [np.max(daily.high[start:end]) for start, end in zip(starts, ends, strict=True)] + ), + np.asarray([np.min(daily.low[start:end]) for start, end in zip(starts, ends, strict=True)]), + daily.close[ends - 1], + np.asarray( + [np.sum(daily.volume[start:end]) for start, end in zip(starts, ends, strict=True)] + ), + daily.symbol, + "1W", + "MAX", + ) + + +def _slice_history(source: OHLCV, range_key: str) -> OHLCV: + if range_key == "MAX": + return source + cutoff = _AS_OF64 - np.timedelta64(_RANGE_DAYS[range_key], "D") + start = int(np.searchsorted(source.dates, cutoff, side="left")) + return OHLCV( + source.dates[start:], + source.open[start:], + source.high[start:], + source.low[start:], + source.close[start:], + source.volume[start:], + source.symbol, + source.resolution, + range_key, + ) + + +@cache +def _history_cached(symbol: str, resolution: str, range_key: str) -> OHLCV: + daily = _daily_history(symbol) + source = daily if resolution == "1D" else _weekly(daily) + return _slice_history(source, range_key) + + +def history(symbol: str, resolution: str = "1D", range_key: str = "MAX") -> OHLCV: + """Return cached read-only history for a supported symbol/resolution/range.""" + + key = instrument(symbol).symbol + normalized_resolution = resolution.upper().strip() + normalized_range = range_key.upper().strip() + if normalized_resolution not in _RESOLUTIONS: + raise ValueError(f"resolution must be one of {sorted(_RESOLUTIONS)}") + if normalized_range not in _RANGES: + raise ValueError(f"range_key must be one of {sorted(_RANGES)}") + return _history_cached(key, normalized_resolution, normalized_range) + + +@cache +def quote(symbol: str) -> Quote: + meta = instrument(symbol) + values = history(meta.symbol) + previous = float(values.close[-2]) + last = float(values.close[-1]) + change = last - previous + return Quote( + symbol=meta.symbol, + name=meta.name, + asset_class=meta.asset_class, + last=last, + change=change, + change_percent=change / previous * 100.0, + open=float(values.open[-1]), + high=float(values.high[-1]), + low=float(values.low[-1]), + volume=float(values.volume[-1]), + ) + + +def _quote_row(value: Quote) -> dict[str, Any]: + row = asdict(value) + row["direction"] = "UP" if value.change > 0 else "DOWN" if value.change < 0 else "FLAT" + row["last_display"] = f"{value.last:,.{instrument(value.symbol).price_decimals}f}" + row["change_display"] = f"{value.change:+,.{instrument(value.symbol).price_decimals}f}" + row["change_percent_display"] = f"{value.change_percent:+.2f}%" + return row + + +def watchlist_rows() -> tuple[dict[str, Any], ...]: + return tuple(_quote_row(quote(symbol)) for symbol in _WATCHLIST) + + +def movers(limit: int = 5) -> tuple[Quote, ...]: + if limit <= 0: + return () + values = sorted( + (quote(symbol) for symbol in _WATCHLIST), + key=lambda item: abs(item.change_percent), + reverse=True, + ) + return tuple(values[:limit]) + + +def breadth_metrics() -> dict[str, float | int]: + equity_symbols = tuple( + symbol + for symbol, meta in INSTRUMENTS.items() + if meta.asset_class in {"Equity", "Equity ETF"} + ) + quotes = tuple(quote(symbol) for symbol in equity_symbols) + above_20d = sum( + history(symbol, range_key="1M").close[-1] > np.mean(history(symbol, range_key="1M").close) + for symbol in equity_symbols + ) + advances = sum(value.change > 0 for value in quotes) + return { + "advancers": advances, + "decliners": len(quotes) - advances, + "advance_decline": float(advances - (len(quotes) - advances)), + "average_change_percent": float(np.mean([value.change_percent for value in quotes])), + "above_20d": int(above_20d), + "universe": len(quotes), + } + + +def market_heatmap_data() -> tuple[tuple[str, ...], tuple[str, ...], np.ndarray]: + ranges = ("1D", "1M", "3M") + matrix = np.empty((len(ranges), len(_SYMBOLS)), dtype=np.float64) + for row, range_key in enumerate(ranges): + for column, symbol in enumerate(_SYMBOLS): + values = history(symbol, range_key="MAX" if range_key == "1D" else range_key) + first = float(values.close[-2] if range_key == "1D" else values.close[0]) + matrix[row, column] = (float(values.close[-1]) / first - 1.0) * 100.0 + return _SYMBOLS, ranges, _readonly(matrix) + + +def yield_curve() -> tuple[tuple[str, ...], np.ndarray, np.ndarray]: + tenors = ("3M", "6M", "1Y", "2Y", "5Y", "10Y", "20Y", "30Y") + years = _readonly([0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 30.0]) + rates = _readonly([4.68, 4.49, 4.18, 3.96, 4.08, 4.31, 4.61, 4.52]) + return tenors, years, rates + + +@lru_cache(maxsize=1) +def pulse_seed() -> tuple[np.ndarray, np.ndarray]: + rng = np.random.default_rng(991) + x = np.arange(36, dtype=np.float64) + y = 100.0 + np.cumsum(rng.normal(0.0, 0.16, len(x))) + return _readonly(x), _readonly(y) + + +def positions() -> tuple[Position, ...]: + return _POSITIONS + + +def position_rows() -> tuple[dict[str, Any], ...]: + rows: list[dict[str, Any]] = [] + for item in _POSITIONS: + last = quote(item.symbol).last + market_value = item.quantity * last + cost = item.quantity * item.average_cost + pnl = market_value - cost + rows.append( + { + **asdict(item), + "last": last, + "market_value": market_value, + "cost_basis": cost, + "pnl": pnl, + "pnl_percent": pnl / cost * 100.0, + "pnl_pct": pnl / cost * 100.0, + } + ) + return tuple(rows) + + +def portfolio_summary() -> dict[str, float]: + rows = position_rows() + market_value = float(sum(row["market_value"] for row in rows)) + cost_basis = float(sum(row["cost_basis"] for row in rows)) + pnl = market_value - cost_basis + nav = _PORTFOLIO_CASH + market_value + return { + "cash": _PORTFOLIO_CASH, + "market_value": market_value, + "cost_basis": cost_basis, + "pnl": pnl, + "pnl_percent": pnl / cost_basis * 100.0, + "nav": nav, + "gross_exposure_percent": market_value / nav * 100.0, + } + + +@cache +def portfolio_equity(range_key: str = "MAX") -> PortfolioSeries: + normalized_range = range_key.upper().strip() + if normalized_range not in _RANGES: + raise ValueError(f"range_key must be one of {sorted(_RANGES)}") + source = history(_POSITIONS[0].symbol, range_key=normalized_range) + equity = np.full(len(source), _PORTFOLIO_CASH, dtype=np.float64) + for item in _POSITIONS: + equity += item.quantity * history(item.symbol, range_key=normalized_range).close + pnl = equity - equity[0] + returns = np.diff(equity) / equity[:-1] + return PortfolioSeries(source.dates, equity, returns, pnl) + + +def portfolio_returns(range_key: str = "1Y") -> np.ndarray: + return portfolio_equity(range_key).returns + + +def portfolio_allocation() -> tuple[tuple[str, ...], np.ndarray]: + rows = position_rows() + values = np.asarray([row["market_value"] for row in rows], dtype=np.float64) + return tuple(row["symbol"] for row in rows), _readonly(values / np.sum(values) * 100.0) + + +def portfolio_contribution() -> tuple[tuple[str, ...], np.ndarray]: + rows = position_rows() + return tuple(row["symbol"] for row in rows), _readonly([row["pnl"] for row in rows]) + + +def sector_exposures() -> tuple[tuple[str, ...], np.ndarray]: + grouped: dict[str, float] = {} + for row in position_rows(): + sector = instrument(row["symbol"]).sector + grouped[sector] = grouped.get(sector, 0.0) + float(row["market_value"]) + nav = portfolio_summary()["nav"] + return tuple(grouped), _readonly([value / nav * 100.0 for value in grouped.values()]) + + +def correlation_matrix( + symbols: Sequence[str] | None = None, range_key: str = "1Y" +) -> tuple[tuple[str, ...], np.ndarray]: + selected = tuple( + instrument(symbol).symbol + for symbol in (symbols or ("SPY", "AAPL", "MSFT", "NVDA", "JPM", "XOM")) + ) + if len(selected) < 2: + raise ValueError("correlation_matrix requires at least two symbols") + columns = [] + for symbol in selected: + close = history(symbol, range_key=range_key).close + columns.append(np.diff(close) / close[:-1]) + matrix = np.corrcoef(np.vstack(columns)) + if not np.isfinite(matrix).all(): + raise ValueError("correlation matrix contains non-finite values") + return selected, _readonly(matrix) + + +def factor_exposures() -> tuple[tuple[str, ...], np.ndarray]: + rows = position_rows() + total = sum(float(row["market_value"]) for row in rows) + weights = {row["symbol"]: float(row["market_value"]) / total for row in rows} + market = sum(weights[symbol] * instrument(symbol).beta for symbol in weights) + technology = sum( + weights[symbol] for symbol in weights if instrument(symbol).sector == "Technology" + ) + defensive = sum( + weights[symbol] for symbol in weights if instrument(symbol).sector in {"Energy", "Metals"} + ) + dollar = sum(weights[symbol] for symbol in weights if instrument(symbol).currency == "USD") + alternatives = sum( + weights[symbol] + for symbol in weights + if instrument(symbol).asset_class in {"Commodity", "Crypto"} + ) + labels = ("Market beta", "Technology", "Defensive", "USD", "Alternatives") + return labels, _readonly([market, technology, defensive, dollar, alternatives]) + + +def _normalize_confidence(value: float | int | str) -> float: + if isinstance(value, str): + value = float(value.strip().rstrip("%")) + confidence = float(value) + if confidence > 1.0: + confidence /= 100.0 + if confidence not in {0.95, 0.99}: + raise ValueError("confidence must be 0.95/95% or 0.99/99%") + return confidence + + +def stress_scenarios(confidence: float | int | str = 0.95) -> tuple[ScenarioResult, ...]: + normalized = _normalize_confidence(confidence) + multiplier = 1.0 if normalized == 0.95 else 1.18 + nav = portfolio_summary()["nav"] + values = {row["symbol"]: float(row["market_value"]) for row in position_rows()} + definitions: tuple[tuple[str, str, Mapping[str, float]], ...] = ( + ( + "Equity selloff", + "Broad risk assets gap lower; gold provides a partial hedge.", + { + "SPY": -0.12, + "AAPL": -0.16, + "MSFT": -0.15, + "NVDA": -0.24, + "JPM": -0.17, + "XOM": -0.10, + "XAUUSD": 0.045, + "BTCUSD": -0.27, + }, + ), + ( + "Rates +150 bp", + "Long-duration growth reprices while financials are comparatively resilient.", + { + "SPY": -0.07, + "AAPL": -0.10, + "MSFT": -0.11, + "NVDA": -0.16, + "JPM": -0.025, + "XOM": -0.04, + "XAUUSD": -0.08, + "BTCUSD": -0.13, + }, + ), + ( + "Energy shock", + "Oil-linked assets rally as margins and consumer risk deteriorate.", + { + "SPY": -0.045, + "AAPL": -0.04, + "MSFT": -0.035, + "NVDA": -0.06, + "JPM": -0.05, + "XOM": 0.18, + "XAUUSD": 0.025, + "BTCUSD": -0.07, + }, + ), + ( + "Dollar squeeze", + "USD liquidity pressure hits alternatives and multinational earnings.", + { + "SPY": -0.04, + "AAPL": -0.055, + "MSFT": -0.05, + "NVDA": -0.075, + "JPM": -0.03, + "XOM": -0.035, + "XAUUSD": -0.09, + "BTCUSD": -0.16, + }, + ), + ) + results = [] + for name, description, shocks in definitions: + pnl = multiplier * sum(values[symbol] * shock for symbol, shock in shocks.items()) + results.append( + ScenarioResult( + name=name, + description=description, + confidence=normalized, + pnl=float(pnl), + loss_percent=float(pnl / nav * 100.0), + nav_after=float(nav + pnl), + ) + ) + return tuple(results) + + +_STORIES = ( + NewsItem( + "N-001", + "2026-07-31T15:42:00Z", + "XY Wire", + "Semiconductor complex leads late-session rebound", + "A broad technology bid accelerated after systematic flows turned positive into the close. All values and events in this terminal are simulated.", + ("NVDA", "MSFT", "SPY"), + "Positive", + "High", + ), + NewsItem( + "N-002", + "2026-07-31T14:18:00Z", + "Terminal Research", + "Yield curve steepens as front-end expectations ease", + "The simulated curve bull-steepened after a softer activity proxy, while long-end term premium remained firm.", + ("SPY", "JPM", "XAUUSD"), + "Mixed", + "High", + ), + NewsItem( + "N-003", + "2026-07-31T12:05:00Z", + "Market Desk", + "Dollar pauses; gold holds above technical support", + "G10 FX volatility compressed and the fictional spot-gold series consolidated above its 20-day average.", + ("EURUSD", "USDJPY", "XAUUSD"), + "Neutral", + "Medium", + ), + NewsItem( + "N-004", + "2026-07-31T10:31:00Z", + "Digital Ledger", + "Crypto beta rises with broader risk appetite", + "Bitcoin's simulated realized volatility moved higher as cross-asset correlations strengthened.", + ("BTCUSD", "SPY", "NVDA"), + "Positive", + "Medium", + ), + NewsItem( + "N-005", + "2026-07-31T09:12:00Z", + "Energy Brief", + "Integrated energy shares lag despite firm commodity tape", + "Refining-margin concerns offset a fictional increase in spot energy benchmarks.", + ("XOM", "SPY"), + "Negative", + "Medium", + ), + NewsItem( + "N-006", + "2026-07-30T20:45:00Z", + "Global Close", + "Asia handoff points to cautious open", + "Index futures were little changed in the deterministic overnight scenario; no live venue data is used.", + ("SPY", "USDJPY"), + "Neutral", + "Low", + ), +) + +_CALENDAR = ( + CalendarEvent( + "C-001", "2026-08-03T14:00:00Z", "US", "ISM Manufacturing", "High", "--", "49.8", "49.2" + ), + CalendarEvent( + "C-002", "2026-08-04T04:30:00Z", "AU", "RBA Rate Decision", "High", "--", "3.60%", "3.60%" + ), + CalendarEvent( + "C-003", + "2026-08-05T12:15:00Z", + "US", + "ADP Employment Change", + "Medium", + "--", + "118K", + "105K", + ), + CalendarEvent( + "C-004", "2026-08-06T11:00:00Z", "GB", "BoE Bank Rate", "High", "--", "3.75%", "4.00%" + ), + CalendarEvent( + "C-005", "2026-08-07T12:30:00Z", "US", "Nonfarm Payrolls", "High", "--", "165K", "142K" + ), +) + + +def stories(symbol: str | None = None) -> tuple[NewsItem, ...]: + if symbol is None: + return _STORIES + key = instrument(symbol).symbol + return tuple(item for item in _STORIES if key in item.symbols) + + +def calendar_events() -> tuple[CalendarEvent, ...]: + return _CALENDAR + + +__all__ = [ + "AS_OF", + "INSTRUMENTS", + "OHLCV", + "SIMULATED_DATA_LABEL", + "CalendarEvent", + "Instrument", + "NewsItem", + "PortfolioSeries", + "Position", + "Quote", + "ScenarioResult", + "breadth_metrics", + "calendar_events", + "correlation_matrix", + "factor_exposures", + "history", + "instrument", + "instrument_symbols", + "market_heatmap_data", + "movers", + "portfolio_allocation", + "portfolio_contribution", + "portfolio_equity", + "portfolio_returns", + "portfolio_summary", + "position_rows", + "positions", + "pulse_seed", + "quote", + "sector_exposures", + "stories", + "stress_scenarios", + "watchlist_rows", + "yield_curve", +] diff --git a/examples/reflex/xy_reflex_demo/state.py b/examples/reflex/xy_reflex_demo/state.py new file mode 100644 index 00000000..e10b75d7 --- /dev/null +++ b/examples/reflex/xy_reflex_demo/state.py @@ -0,0 +1,433 @@ +"""Small Reflex state surface for the XY terminal example. + +Large market series live in :mod:`.data`'s module-level caches. This state +only records the user's current terminal selections and small interaction +readouts; chart builders resolve the cached arrays when a figure var changes. +""" + +from __future__ import annotations + +import asyncio +import math +from typing import Any + +import reflex as rx + +import reflex_xy + +from . import charts, data + +WORKSPACES = ("MARKETS", "SECURITY", "PORTFOLIO", "RISK", "NEWS") +RANGES = ("1M", "3M", "6M", "1Y", "MAX") +RESOLUTIONS = ("1D", "1W") +OVERLAYS = ("SMA 20", "EMA 50", "Bollinger", "VWAP", "Anchored VWAP", "Volume Profile") +OSCILLATORS = ("None", "RSI", "MACD", "Stochastic") +DRAWINGS = ( + "None", + "Long position", + "Short position", + "Forecast", + "Bars pattern", + "Ghost feed", + "XABCD", +) +CONFIDENCE_LEVELS = ("95%", "99%") + + +def _number(value: str) -> float | None: + """Parse a finite terminal input without allowing NaN/inf downstream.""" + try: + result = float(value) + except (TypeError, ValueError): + return None + return result if math.isfinite(result) else None + + +def _symbols() -> tuple[str, ...]: + return tuple(str(symbol).upper() for symbol in data.instrument_symbols()) + + +_TAPE_BASE = tuple( + ( + str(row["symbol"]), + float(row["last"]), + float(row["change_percent"]), + int(data.instrument(str(row["symbol"])).price_decimals), + ) + for row in data.watchlist_rows() +) + + +def _tape_rows(step: int) -> list[dict[str, str]]: + """Return ten compact, deterministic display rows for the live tape.""" + rows: list[dict[str, str]] = [] + for index, (symbol, baseline, base_change, decimals) in enumerate(_TAPE_BASE): + wobble = math.sin((step + index * 2.0) / 5.0) * 0.0012 + wobble += math.sin((step + index * 7.0) / 13.0) * 0.0005 + last = baseline * (1.0 + wobble) + change = base_change + wobble * 100.0 + rows.append( + { + "symbol": symbol, + "last": f"{last:,.{decimals}f}", + "change": f"{change:+.2f}%", + "direction": "UP" if change >= 0 else "DOWN", + } + ) + return rows + + +def _ticket_prices(symbol: str, side: str) -> tuple[str, str, str]: + quote = data.quote(symbol) + decimals = data.instrument(symbol).price_decimals + entry = float(quote.last) + if side == "Long": + stop, target = entry * 0.97, entry * 1.06 + else: + stop, target = entry * 1.03, entry * 0.94 + return tuple(f"{value:.{decimals}f}" for value in (entry, stop, target)) + + +_INITIAL_ENTRY, _INITIAL_STOP, _INITIAL_TARGET = _ticket_prices("AAPL", "Long") + + +class TerminalState(rx.State): + """Interaction state for the single-page terminal shell.""" + + workspace: str = "MARKETS" + command: str = "" + command_status: str = "READY — TRY MKTS, DES AAPL, PORT, RISK, NEWS, OR HELP" + help_visible: bool = False + + selected_symbol: str = "AAPL" + range_key: str = "6M" + resolution: str = "1D" + overlays: list[str] = ["SMA 20", "VWAP"] + oscillator: str = "RSI" + drawing: str = "Long position" + + hovered: dict[str, Any] = {} + view_status: str = "FULL HISTORY" + + streaming: bool = False + _stream_step: int = 35 + tape_quotes: list[dict[str, str]] = _tape_rows(35) + + ticket_side: str = "Long" + ticket_entry: str = _INITIAL_ENTRY + ticket_stop: str = _INITIAL_STOP + ticket_target: str = _INITIAL_TARGET + ticket_account: str = "100000" + ticket_risk: str = "1.00" + + confidence_label: str = "95%" + selected_scenario: str = "Equity selloff" + selected_story: str = "N-001" + + developer_open: bool = False + developer_tab: str = "SOURCE" + + def _raw_ticket(self) -> dict[str, Any]: + return { + "symbol": self.selected_symbol, + "side": self.ticket_side.lower(), + "entry": _number(self.ticket_entry), + "stop": _number(self.ticket_stop), + "target": _number(self.ticket_target), + "account_size": _number(self.ticket_account), + "risk_percent": _number(self.ticket_risk), + } + + def _ticket_result(self) -> dict[str, Any]: + payload = self._raw_ticket() + if any(value is None for key, value in payload.items() if key not in {"side", "symbol"}): + return {"valid": False, "error": "ENTER FINITE NUMERIC TICKET VALUES"} + try: + return dict(charts.ticket_metrics(payload)) + except (TypeError, ValueError, ZeroDivisionError) as exc: + return {"valid": False, "error": str(exc).upper()} + + def _reset_ticket_prices(self) -> None: + self.ticket_entry, self.ticket_stop, self.ticket_target = _ticket_prices( + self.selected_symbol, self.ticket_side + ) + + @rx.var + def confidence(self) -> float: + return 0.99 if self.confidence_label == "99%" else 0.95 + + @rx.var + def ticket_valid(self) -> bool: + return bool(self._ticket_result().get("valid")) + + @rx.var + def ticket_error(self) -> str: + result = self._ticket_result() + return "" if result.get("valid") else str(result.get("error") or "INVALID TICKET") + + @rx.var + def ticket_risk_amount(self) -> str: + value = self._ticket_result().get("risk_amount") + return "—" if value is None else f"${float(value):,.2f}" + + @rx.var + def ticket_position_size(self) -> str: + value = self._ticket_result().get("quantity") + return "—" if value is None else f"{float(value):,.2f}" + + @rx.var + def ticket_reward_risk(self) -> str: + value = self._ticket_result().get("risk_reward") + return "—" if value is None else f"{float(value):.2f}×" + + @rx.var + def state_snapshot(self) -> str: + return ( + f"workspace={self.workspace}\n" + f"symbol={self.selected_symbol} range={self.range_key} resolution={self.resolution}\n" + f"overlays={','.join(self.overlays) or 'none'}\n" + f"oscillator={self.oscillator} drawing={self.drawing}\n" + f"confidence={self.confidence_label} scenario={self.selected_scenario}\n" + f"streaming={self.streaming}" + ) + + @reflex_xy.figure + def security_figure(self): + ticket = self._raw_ticket() if self._ticket_result().get("valid") else None + return charts.security_chart( + self.selected_symbol, + range_key=self.range_key, + resolution=self.resolution, + overlays=tuple(self.overlays), + oscillator=self.oscillator, + drawing=self.drawing, + ticket=ticket, + ) + + @reflex_xy.figure + def portfolio_figure(self): + return charts.portfolio_performance_chart() + + @reflex_xy.figure + def risk_figure(self): + return charts.risk_distribution_chart(self.confidence) + + @reflex_xy.figure + def market_pulse(self): + return charts.market_pulse_chart() + + @rx.event + def choose_workspace(self, workspace: str): + target = workspace.strip().upper() + if target in WORKSPACES: + self.workspace = target + self.command_status = f"{target} WORKSPACE" + self.help_visible = False + + @rx.event + def set_command(self, value: str): + self.command = value + + def _execute_command(self) -> None: + raw = self.command.strip() + self.help_visible = False + if not raw: + self.command_status = "ENTER A COMMAND — HELP LISTS AVAILABLE FUNCTIONS" + self.command = "" + return + parts = raw.upper().split() + verb = parts[0] + if verb == "MKTS" and len(parts) == 1: + self.workspace = "MARKETS" + self.command_status = "MKTS — GLOBAL MARKET MONITOR" + elif verb == "DES" and len(parts) == 2: + symbol = parts[1] + if symbol in _symbols(): + self.selected_symbol = symbol + self._reset_ticket_prices() + self.workspace = "SECURITY" + self.command_status = f"DES {symbol} — SECURITY DESCRIPTION" + else: + self.command_status = f"UNKNOWN SECURITY: {symbol}" + elif verb in {"PORT", "RISK", "NEWS"} and len(parts) == 1: + self.workspace = {"PORT": "PORTFOLIO", "RISK": "RISK", "NEWS": "NEWS"}[verb] + self.command_status = f"{verb} — {self.workspace} WORKSPACE" + elif verb == "HELP" and len(parts) == 1: + self.help_visible = True + self.command_status = "HELP — MKTS · DES · PORT · RISK · NEWS" + else: + self.command_status = f"UNKNOWN COMMAND: {raw.upper()} — TYPE HELP" + self.command = "" + + @rx.event + def execute_command(self): + self._execute_command() + + @rx.event + def command_key(self, key: str): + if key == "Enter": + self._execute_command() + + @rx.event + def select_symbol(self, symbol: str): + candidate = symbol.upper() + if candidate not in _symbols(): + self.command_status = f"UNKNOWN SECURITY: {candidate}" + return + self.selected_symbol = candidate + self._reset_ticket_prices() + self.workspace = "SECURITY" + self.command_status = f"DES {candidate} — SECURITY DESCRIPTION" + + @rx.event + def set_range_key(self, value: str): + if value in RANGES: + self.range_key = value + + @rx.event + def set_resolution(self, value: str): + if value in RESOLUTIONS: + self.resolution = value + + @rx.event + def toggle_overlay(self, overlay: str): + if overlay not in OVERLAYS: + return + if overlay in self.overlays: + self.overlays = [item for item in self.overlays if item != overlay] + else: + self.overlays = [*self.overlays, overlay] + + @rx.event + def set_oscillator(self, value: str): + if value in OSCILLATORS: + self.oscillator = value + + @rx.event + def set_drawing(self, value: str): + if value in DRAWINGS: + self.drawing = value + if value in {"Long position", "Short position"}: + self.ticket_side = "Long" if value == "Long position" else "Short" + self._reset_ticket_prices() + + @rx.event + def set_ticket_side(self, value: str): + if value in {"Long", "Short"}: + self.ticket_side = value + self._reset_ticket_prices() + if self.drawing in {"Long position", "Short position"}: + self.drawing = f"{value} position" + + @rx.event + def set_ticket_entry(self, value: str): + self.ticket_entry = value + + @rx.event + def set_ticket_stop(self, value: str): + self.ticket_stop = value + + @rx.event + def set_ticket_target(self, value: str): + self.ticket_target = value + + @rx.event + def set_ticket_account(self, value: str): + self.ticket_account = value + + @rx.event + def set_ticket_risk(self, value: str): + self.ticket_risk = value + + @rx.event + def drilldown_position(self, symbol: str): + self.selected_symbol = symbol.upper() + self._reset_ticket_prices() + self.workspace = "SECURITY" + self.command_status = f"PORT → DES {self.selected_symbol}" + + @rx.event + def set_confidence(self, value: str): + if value in CONFIDENCE_LEVELS: + self.confidence_label = value + + @rx.event + def set_scenario(self, value: str): + self.selected_scenario = value + + @rx.event + def select_story(self, story_id: str): + self.selected_story = story_id + + @rx.event + def toggle_developer(self): + self.developer_open = not self.developer_open + + @rx.event + def set_developer_tab(self, tab: str): + if tab in {"SOURCE", "STATE", "SPEC"}: + self.developer_tab = tab + + @rx.event + def on_chart_hover(self, event: dict[str, Any]): + """Reduce the structured hover payload to a compact terminal readout.""" + if not event.get("active"): + self.hovered = {} + return + points = event.get("points") or [] + point = points[0] if points else {} + row = point.get("row") or {} + cursor = (event.get("cursor") or {}).get("data") or {} + x_axis = str(point.get("x_axis") or "x") + y_axis = str(point.get("y_axis") or "y") + self.hovered = { + "x": row.get("x", cursor.get(x_axis, "—")), + "y": row.get("y", cursor.get(y_axis, "—")), + "trace": point.get("trace", "cursor"), + } + + @rx.event + def on_chart_view(self, event: reflex_xy.ViewChangeEvent): + x_domain = event.get("x_domain") or [] + if len(x_domain) == 2: + self.view_status = f"VIEW {float(x_domain[0]):.2f} → {float(x_domain[1]):.2f}" + else: + self.view_status = "VIEW UPDATED" + + @rx.event(background=True) + async def stream_quotes(self): + """Start/stop the single market-pulse producer for this state token.""" + async with self: + if self.streaming: + self.streaming = False + return + self.streaming = True + token = self.market_pulse + while True: + async with self: + if not self.streaming or token != self.market_pulse: + break + self._stream_step += 1 + step = self._stream_step + self.tape_quotes = _tape_rows(step) + value = 100.0 + math.sin(step / 3.25) * 0.8 + math.sin(step / 11.0) * 1.4 + reflex_xy.append(token, x=[float(step)], y=[float(value)]) + await asyncio.sleep(0.8) + + +# A short alias keeps the example approachable in live notebooks and avoids +# breaking links that imported the former showcase's state class. +Demo = TerminalState + + +__all__ = [ + "CONFIDENCE_LEVELS", + "DRAWINGS", + "OSCILLATORS", + "OVERLAYS", + "RANGES", + "RESOLUTIONS", + "WORKSPACES", + "Demo", + "TerminalState", +] diff --git a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py index 160b9b0e..723f0ea0 100644 --- a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py +++ b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py @@ -1,722 +1,17 @@ -"""XY Reflex showcase: ways to link chart data into a Reflex app. +"""XY Terminal: a deterministic multi-workspace Reflex example. -One page of six sections; each has a "Code" accordion showing its own source -via `inspect.getsource`. - -1. **Live figure var + events.** A 1M-point drillable scatter from an - ``@reflex_xy.figure`` state method; its data rides the app websocket while - Reflex state holds only the token. Hover, click, and box-select arrive as - ordinary Reflex events. -2. **A chart driven by state vars.** A histogram whose bin count is a slider var - and whose data is cross-filtered by §1's box-selection; changing either - recomputes the figure and re-publishes it under a stable token. -3. **A dynamically updating chart.** A line grown from a background task via - ``reflex_xy.append``. -4. **Data computed from ``on_view_change``.** Pan/zoom an overview scatter; a - detail figure recomputes from the window the view-change event reports. -5. **Fixed data, two ways.** A ``xy.Chart`` passed straight to - ``reflex_xy.chart`` (static payload tier) and a ``reflex_xy.inline`` token - (fixed data served through the kernel). -6. **The drilldown, adapter-native.** The 100M-point live drilldown - scatter from ``examples/fastapi`` — identical data and mark config — as one - ``reflex_xy.inline`` token with zero transport code, for A/B-ing the two - hosts. ``XY_LIVE_POINTS`` resizes it (both apps honor the same override). -7. **Legend hover-highlight and click-to-toggle.** Left: named series on the - direct tier — hovering a legend row dims the others, clicking hides a - series entirely client-side (interaction spec §9/§10). Right: a categorical - density scatter behind an ``inline()`` token — clicking a category row - sends ``legend_toggle`` over the app websocket and the kernel re-bins the - surface with that category masked out (§34). - -Run from ``examples/reflex``:: - - uv run reflex run +Run from ``examples/reflex`` with ``uv run reflex run``. The page uses no +runtime network services or API keys; every market value is simulated. """ from __future__ import annotations -import asyncio -import inspect -import os -import warnings -from functools import lru_cache -from typing import Any - -import numpy as np import reflex as rx -import reflex_xy -import xy -from reflex_xy.tokens import BUILDER_ATTR - -POINTS = 1_000_000 -RNG_SEED = 11 - - -# --- shared source data ----------------------------------------------------- -# Shared columns are built once at module scope and cached; the figure builders -# read them and stay pure functions of state. - - -@lru_cache(maxsize=1) -def _cloud(n: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - rng = np.random.default_rng(RNG_SEED) - x = rng.normal(0.0, 1.0, n) - y = x * 0.55 + rng.normal(0.0, 0.55, n) - return x, y, np.hypot(x, y) - - -@lru_cache(maxsize=1) -def _scan(n: int) -> tuple[np.ndarray, np.ndarray]: - """An overview cloud whose y-distribution varies along x, so zooming into - different x-windows yields a visibly different detail histogram.""" - rng = np.random.default_rng(5) - x = rng.uniform(0.0, 100.0, n) - y = np.sin(x / 6.0) * 12.0 + x * 0.15 + rng.normal(0.0, 4.0, n) - return x, y - - -async def _magnitudes() -> tuple[np.ndarray, np.ndarray]: - """Async data source for the histogram builder; awaits like a database or - HTTP fetch would.""" - await asyncio.sleep(0) - x, _, mag = _cloud(POINTS) - return x, mag - - -# --- fixed-data charts (module scope) --------------------------------------- - - -def sparkline_chart() -> xy.Chart: - """A fixed chart passed directly to ``reflex_xy.chart``, which compiles it - to a static payload asset.""" - t = np.linspace(0.0, 6.0 * np.pi, 4000) - decay = np.exp(-t / 9.0) - return xy.line_chart( - xy.line(t, np.sin(t) * decay, name="signal"), - xy.line(t, decay, name="envelope"), - xy.x_axis(label="t"), - title="static payload tier", - width="100%", - height=240, - ) - - -def orbits_chart() -> xy.Chart: - """Fixed data registered with ``reflex_xy.inline`` and served through the - kernel for hover/pick under a content-addressed token.""" - rng = np.random.default_rng(3) - n = 400_000 - theta = rng.uniform(0.0, 2.0 * np.pi, n) - r = rng.normal(1.0, 0.05, n) * (1.0 + 0.4 * np.sin(theta * 3.0)) - return xy.scatter_chart( - xy.scatter(r * np.cos(theta), r * np.sin(theta), opacity=0.6, density=True), - xy.x_axis(label="x"), - xy.y_axis(label="y"), - title="inline() token", - width="100%", - height=240, - ) - - -# Registered at import; the content-addressed token resolves on any backend -# worker. -ORBITS_TOKEN = reflex_xy.inline(orbits_chart()) - - -# --- legend interactivity (§7) ---------------------------------------------- - - -def legend_series_chart() -> xy.Chart: - """Three named series on the direct tier. Hovering a legend row dims the - other series; clicking a row hides its series — a pure client hide (0 wire - bytes), so both work even on this static-payload chart. Defaults are on; - ``xy.legend(highlight=False)`` / ``xy.legend(toggle=False)`` opt out.""" - rng = np.random.default_rng(7) - marks = [ - xy.scatter( - rng.normal(cx, 0.5, 60_000), - rng.normal(cy, 0.5, 60_000), - name=name, - opacity=0.7, - ) - for name, cx, cy in ( - ("baseline", -1.5, -0.8), - ("candidate", 0.0, 0.9), - ("control", 1.6, -0.4), - ) - ] - return xy.scatter_chart( - *marks, - xy.legend(), - xy.x_axis(label="x"), - xy.y_axis(label="y"), - title="named series — hover dims, click hides", - width="100%", - height=300, - ) - - -def legend_category_chart() -> xy.Chart: - """One categorical density scatter: a legend row per category. Clicking a - row sends ``legend_toggle`` over the app websocket; the kernel drops that - category before re-binning the density surface (§34 — the reply's binning - is tagged ``-masked``) and the retained sample overlay filters instantly - while the re-bin is in flight.""" - rng = np.random.default_rng(21) - n = 1_200_000 - cat = rng.integers(0, 3, n) - centers = np.array([[-1.2, -0.6], [0.2, 1.1], [1.5, -0.9]]) - x = rng.normal(centers[cat, 0], 0.55) - y = rng.normal(centers[cat, 1], 0.55) - labels = np.array(["sensor A", "sensor B", "sensor C"])[cat] - return xy.scatter_chart( - xy.scatter(x, y, color=labels, opacity=0.7, density=True), - xy.legend(), - xy.x_axis(label="x"), - xy.y_axis(label="y"), - title="categorical density — click a row to mask & re-bin", - width="100%", - height=300, - ) - - -# Kernel-served so category toggles reach `legend_toggle` and the masked -# re-bin path; a static payload would only filter the local sample overlay. -LEGEND_CATS_TOKEN = reflex_xy.inline(legend_category_chart()) - - -# --- the live drilldown, adapter-native (§6) -------------------------- - - -def _drilldown_points() -> int: - """Point count for the §6 drilldown chart, from ``XY_LIVE_POINTS`` — the - same override ``examples/fastapi`` honors, so both apps build the identical - dataset at any size.""" - raw = os.environ.get("XY_LIVE_POINTS") - if raw is None: - return 100_000_000 - try: - points = int(raw) - except ValueError: - points = 0 - if points < 1: - warnings.warn( - f"XY_LIVE_POINTS={raw!r} is not a positive integer; using 100,000,000", - RuntimeWarning, - stacklevel=2, - ) - return 100_000_000 - return points - - -DRILLDOWN_POINTS = _drilldown_points() - - -def _point_label(n: int) -> str: - if n % 1_000_000 == 0: - return f"{n // 1_000_000}M" - if n % 1_000 == 0: - return f"{n // 1_000}k" - return f"{n:,}" - - -def drilldown_chart(n: int = DRILLDOWN_POINTS) -> xy.Chart: - """The ``examples/fastapi`` live-drilldown scatter: same seed, same chunked - generation, same mark config. That app wires the chart through its own - HTTP transport (a Starlette endpoint plus a comm bridge); here the - kernel's density tiers answer every pan/zoom over the app websocket.""" - rng = np.random.default_rng(11) - x = np.empty(n, dtype=np.float64) - y = np.empty(n, dtype=np.float64) - color = np.empty(n, dtype=np.float64) - size = np.empty(n, dtype=np.float64) - chunk = 1_000_000 - for start in range(0, n, chunk): - end = min(start + chunk, n) - xs = rng.normal(0, 1.0, end - start) - ys = rng.normal(0, 0.55, end - start) - ys += xs * 0.55 - ss = rng.normal(6, 2.5, end - start) - np.abs(ss, out=ss) - np.clip(ss, 2, 16, out=ss) - x[start:end] = xs - y[start:end] = ys - np.hypot(xs, ys, out=color[start:end]) - size[start:end] = ss - return xy.scatter_chart( - xy.scatter(x, y, color=color, size=size, colormap="viridis", opacity=0.72, density=True), - xy.x_axis(label="feature A"), - xy.y_axis(label="feature B"), - title=f"{_point_label(n)} live drilldown scatter", - width="100%", - height=430, - ) - - -# One shared kernel-backed figure for every viewer, expressed as a single -# inline() token; the registry keeps it process-global. -DRILLDOWN_TOKEN = reflex_xy.inline(drilldown_chart()) - - -# --- state ------------------------------------------------------------------ - - -class Demo(rx.State): - """Charts are figure vars; everything else is ordinary app state.""" - - # §1 semantic events - hovered: dict = {} - clicked: dict = {} - click_events: int = 0 - select_events: int = 0 - # Click/select handlers bump this and the cloud's title reads it, so every - # event deliberately republishes the source figure behind its stable - # token. The wrapper must keep the viewport and selection across that - # republish without re-dispatching events (no feedback loop) — the - # counters above make a violation visible as a runaway count. - interaction_revision: int = 0 - # §2 state-driven + cross-filter - bins: int = 60 - sel_active: bool = False - sel_x0: float = 0.0 - sel_x1: float = 0.0 - select_note: str = "box-select on the scatter to cross-filter the histogram" - # §3 streaming - streaming: bool = False - _stream_t: float = 0.0 - # §4 viewport-computed detail - view_ready: bool = False - view_x0: float = 0.0 - view_x1: float = 0.0 - visible: int = 0 - - @reflex_xy.figure - def cloud(self) -> xy.Chart: - x, y, mag = _cloud(POINTS) - return xy.scatter_chart( - xy.scatter(x, y, color=mag, colormap="viridis", opacity=0.8, density=True), - # hover and click are off by default; enable them so the point - # events reach the handlers below (select/pan/zoom are on already). - xy.interaction_config(hover=True, click=True), - xy.x_axis(label="feature A"), - xy.y_axis(label="feature B"), - title=( - f"{POINTS // 1_000_000}M points, drillable · " - f"handler revision {self.interaction_revision}" - ), - width="100%", - height=460, - ) - - @reflex_xy.figure - async def histogram(self) -> xy.Chart: - # Reads `bins` and the selection window; changing either re-publishes - # the figure. The async builder may await a data source. - x, mag = await _magnitudes() - if self.sel_active and self.sel_x1 > self.sel_x0: - mag = mag[(x >= self.sel_x0) & (x <= self.sel_x1)] - label = "selection" if self.sel_active else "all points" - return xy.histogram_chart( - xy.histogram(mag, bins=self.bins), - xy.x_axis(label=f"magnitude ({label})"), - title=f"magnitude distribution — {self.bins} bins", - width="100%", - height=240, - ) - - @reflex_xy.figure - def live(self) -> xy.Chart: - return xy.line_chart( - xy.line(np.array([0.0]), np.array([0.0])), - title="live stream", - width="100%", - height=240, - ) - - @reflex_xy.figure - def overview(self) -> xy.Chart: - x, y = _scan(120_000) - return xy.scatter_chart( - xy.scatter(x, y, opacity=0.5, density=True), - xy.interaction_config(zoom_axes=("x",)), - xy.x_axis(label="t"), - xy.y_axis(label="value"), - title="overview — zoom the x range", - width="100%", - height=240, - ) - - @reflex_xy.figure - def detail(self) -> xy.Chart: - # Recomputed from the window the overview last reported through - # `on_view_change`: a histogram of only the y-values currently in view. - x, y = _scan(120_000) - if self.view_ready and self.view_x1 > self.view_x0: - y = y[(x >= self.view_x0) & (x <= self.view_x1)] - title = ( - f"detail — {y.size:,} points in view" - if self.view_ready - else "detail — pan/zoom the overview" - ) - return xy.histogram_chart( - xy.histogram(y, bins=48, color="#7c3aed"), - xy.x_axis(label="value in view"), - title=title, - width="100%", - height=240, - ) - - @rx.event - def on_hover(self, event: reflex_xy.PointHoverEvent): - # v1 point envelope: canonical_row_id + f64 data coordinates. - self.hovered = event.get("data", {}) - - @rx.event - def on_click(self, event: reflex_xy.PointClickEvent): - self.click_events += 1 - self.interaction_revision += 1 - modifiers = event.get("modifiers", {}) - self.clicked = { - "row": event.get("canonical_row_id"), - **event.get("data", {}), - "modifiers": ",".join(k for k, v in modifiers.items() if v) or "none", - } - - @rx.event - def on_select(self, event: reflex_xy.SelectEndEvent): - self.select_events += 1 - self.interaction_revision += 1 - selection = event.get("selection", {}) - total = int(selection.get("total_count") or 0) - bounds = selection.get("data_bounds") or {} - if total and bounds.get("x0") is not None: - self.sel_x0 = float(bounds["x0"]) - self.sel_x1 = float(bounds["x1"]) - self.sel_active = True - self.select_note = ( - f"{total:,} selected · {len(selection.get('rows', [])):,} rows in JSON · " - f"truncated={bool(selection.get('truncated'))}" - ) - else: - self.sel_active = False - self.select_note = "selection cleared" - - @rx.event - def set_bins(self, value: list[int | float]): - self.bins = int(value[0]) - - @rx.event - def on_view(self, event: reflex_xy.ViewChangeEvent): - # `event` is the v1 view-change envelope; `x_domain` is the reported - # [x0, x1] window (throttled by the wrapper, streaming during the - # gesture). Store the window; the `detail` figure var depends on it - # and recomputes. - x_domain = event.get("x_domain") or [0.0, 0.0] - self.view_x0 = float(x_domain[0]) - self.view_x1 = float(x_domain[1]) - self.view_ready = True - x, _ = _scan(120_000) - self.visible = int(((x >= self.view_x0) & (x <= self.view_x1)).sum()) - - @rx.event(background=True) - async def stream(self): - async with self: - if self.streaming: - self.streaming = False - return - self.streaming = True - token = self.live - while True: - async with self: - if not self.streaming or token != self.live: - break - self._stream_t += 1.0 - t = self._stream_t - reflex_xy.append( - token, - x=[t], - y=[float(np.sin(t / 9.0) * 4.0 + np.random.default_rng(int(t)).normal(0, 0.4))], - ) - await asyncio.sleep(0.25) - - -# --- introspection: the "Code" accordions ----------------------------------- - - -def _source(obj: Any) -> str: - """Source of a plain function, an ``@reflex_xy.figure`` var, or an - ``@rx.event`` handler.""" - fget = getattr(obj, "_fget", None) - if fget is not None: # a @reflex_xy.figure / computed var - builder = getattr(fget, BUILDER_ATTR, None) - return inspect.getsource(builder if builder is not None else fget) - handler = getattr(obj, "fn", None) - if handler is not None: # an @rx.event handler - return inspect.getsource(handler) - return inspect.getsource(obj) - - -def code_accordion(*objs: Any) -> rx.Component: - source = "\n\n".join(inspect.cleandoc("\n" + _source(obj)) for obj in objs) - return rx.el.details( - rx.el.summary( - "Code", - cursor="pointer", - padding="0.75rem 1rem", - font_weight="700", - font_size="0.85rem", - list_style="none", - ), - rx.el.pre( - rx.el.code(source), - margin="0", - padding="1rem 1.15rem", - background="#0b1120", - color="#e5e7eb", - font_size="0.78rem", - line_height="1.55", - overflow_x="auto", - white_space="pre", - border_top="1px solid rgba(148,163,184,0.2)", - ), - border_top="1px solid var(--gray-5)", - width="100%", - ) - - -# --- layout ----------------------------------------------------------------- - - -def section(title: str, blurb: str, body: rx.Component, code: rx.Component) -> rx.Component: - return rx.box( - rx.box( - rx.heading(title, size="5"), - rx.text(blurb, color_scheme="gray", size="2", margin_top="0.25rem"), - padding="1rem 1.15rem", - ), - rx.box(body, padding="0 1.15rem 1.15rem"), - code, - border="1px solid var(--gray-5)", - border_radius="12px", - background="var(--gray-1)", - overflow="hidden", - width="100%", - ) - - -def kv(label: str, value: Any) -> rx.Component: - return rx.hstack( - rx.badge(label), - rx.text(value, font_family="monospace", font_size="13px"), - spacing="3", - align="center", - ) - - -# §1 wiring — the live figure var and its semantic events -def cloud_view() -> rx.Component: - return reflex_xy.chart( - Demo.cloud, - on_point_hover=Demo.on_hover, - on_point_click=Demo.on_click, - on_select_end=Demo.on_select, - height="460px", - id="cloud", - ) - - -# §2 wiring — a chart driven by a slider and another chart's selection -def histogram_view() -> rx.Component: - return rx.vstack( - reflex_xy.chart(Demo.histogram, height="240px", id="hist"), - rx.hstack( - rx.text("bins", size="2", color_scheme="gray"), - rx.slider( - default_value=[60], min=20, max=160, step=10, on_change=Demo.set_bins, id="bins" - ), - rx.text(Demo.bins, font_family="monospace", size="2", width="2.5rem"), - width="100%", - align="center", - spacing="3", - ), - rx.text(Demo.select_note, size="2", color_scheme="gray"), - width="100%", - spacing="2", - ) - - -# §3 wiring — a chart that grows from a background task -def live_view() -> rx.Component: - return rx.vstack( - reflex_xy.chart(Demo.live, height="240px", id="live"), - rx.button( - rx.cond(Demo.streaming, "stop stream", "go live"), - on_click=Demo.stream, - id="stream-btn", - ), - width="100%", - spacing="2", - ) - - -# §4 wiring — a detail chart computed from the overview's view-change events -def viewport_view() -> rx.Component: - return rx.grid( - reflex_xy.chart(Demo.overview, on_view_change=Demo.on_view, height="240px", id="overview"), - reflex_xy.chart(Demo.detail, height="240px", id="detail"), - columns="2", - gap="1rem", - width="100%", - ) - - -# §5 wiring — the two fixed-data tiers -def fixed_view() -> rx.Component: - return rx.grid( - reflex_xy.chart(sparkline_chart(), height="240px", id="inline"), - reflex_xy.chart(ORBITS_TOKEN, height="240px", id="orbits"), - columns="2", - gap="1rem", - width="100%", - ) - - -# §6 wiring — the whole drilldown integration is this one line -def drilldown_view() -> rx.Component: - return reflex_xy.chart(DRILLDOWN_TOKEN, height="430px", id="drilldown") - - -# §7 wiring — legend interactivity ships with the charts; no handlers needed -def legend_view() -> rx.Component: - return rx.grid( - reflex_xy.chart(legend_series_chart(), height="300px", id="legend-series"), - reflex_xy.chart(LEGEND_CATS_TOKEN, height="300px", id="legend-cats"), - columns="2", - gap="1rem", - width="100%", - ) - - -def index() -> rx.Component: - return rx.container( - rx.vstack( - rx.heading("xy × reflex", size="8"), - rx.text( - "Chart data rides the app websocket as binary buffers, with " - "kernel-side drilldown. Each section shows its own source below.", - color_scheme="gray", - size="3", - ), - section( - "1 · Live figure var + events", - "A 1M-point drillable scatter from an @reflex_xy.figure method. " - "Zoom to drill density into exact points; hover, click, and box-select. " - "Click and select handlers republish the chart itself (the title's " - "revision) — viewport and selection must survive each republish.", - rx.vstack( - cloud_view(), - kv( - "hover", - rx.cond( - Demo.hovered.length() > 0, - f"x={Demo.hovered['x']} y={Demo.hovered['y']}", - "zoom in to drill, then hover a point", - ), - ), - kv( - "click", - rx.cond( - Demo.clicked.length() > 0, - f"row {Demo.clicked['row']} · x={Demo.clicked['x']} " - f"y={Demo.clicked['y']} · modifiers={Demo.clicked['modifiers']}", - "zoom in to drill, then click a point", - ), - ), - kv( - "events", - f"{Demo.click_events} clicks · {Demo.select_events} selections · " - f"republish revision {Demo.interaction_revision}", - ), - width="100%", - spacing="3", - ), - code_accordion( - Demo.cloud, Demo.on_hover, Demo.on_click, Demo.on_select, cloud_view - ), - ), - section( - "2 · A chart driven by state vars", - "The histogram's bin count is a slider var, and its data is " - "cross-filtered by the box-selection above. Changing either " - "re-publishes the figure under a stable token.", - histogram_view(), - code_accordion(Demo.histogram, Demo.set_bins, histogram_view), - ), - section( - "3 · A dynamically updating chart", - "A line grown by a background task via reflex_xy.append; points " - "are pushed to subscribers as they arrive.", - live_view(), - code_accordion(Demo.live, Demo.stream, live_view), - ), - section( - "4 · Data computed from on_view_change", - "Zoom the overview's x range; the detail histogram recomputes from " - "only the points in view, driven by the view-change event.", - rx.vstack( - viewport_view(), - kv( - "view", - rx.cond( - Demo.view_ready, - f"x ∈ [{Demo.view_x0}, {Demo.view_x1}] · {Demo.visible} points", - "pan or zoom the overview", - ), - ), - width="100%", - spacing="3", - ), - code_accordion(Demo.overview, Demo.detail, Demo.on_view, viewport_view), - ), - section( - "5 · Fixed data, two ways", - "Left: a xy.Chart passed straight to reflex_xy.chart, compiled to " - "a static payload asset. Right: a reflex_xy.inline token, whose " - "fixed data answers hover/pick from the kernel.", - fixed_view(), - code_accordion(sparkline_chart, orbits_chart, fixed_view), - ), - section( - f"6 · The {_point_label(DRILLDOWN_POINTS)} drilldown, adapter-native", - "The live drilldown scatter from examples/fastapi — same data, " - "same mark config — with the adapter replacing that app's custom " - "HTTP transport (Starlette endpoint plus comm bridge). Zoom until " - "the density surface drills into exact points; XY_LIVE_POINTS " - "resizes both apps for side-by-side comparison.", - drilldown_view(), - code_accordion(drilldown_chart, drilldown_view), - ), - section( - "7 · Legend: hover to emphasize, click to toggle", - "Hover a legend row to dim every other series; click it to " - "hide/show what it stands for. Left: named series — a pure " - "client-side hide (0 wire bytes). Right: a categorical density " - "scatter served by the kernel — a category click sends " - "legend_toggle over the app websocket and the surface is " - "re-binned with the category masked out (§34). Both default " - "on: xy.legend(highlight=False) / xy.legend(toggle=False) " - "opt out.", - legend_view(), - code_accordion(legend_series_chart, legend_category_chart, legend_view), - ), - spacing="5", - width="100%", - ), - size="4", - padding_y="28px", - ) - +from .components import index +from .state import Demo, TerminalState app = rx.App() -app.add_page(index, title="XY Reflex showcase") +app.add_page(index, title="XY Terminal · Simulated Markets") + +__all__ = ["Demo", "TerminalState", "app", "index"] diff --git a/tests/test_example_apps.py b/tests/test_example_apps.py index df1b7635..e84ef1c2 100644 --- a/tests/test_example_apps.py +++ b/tests/test_example_apps.py @@ -7,6 +7,7 @@ from __future__ import annotations +import importlib import importlib.util import inspect import os @@ -14,13 +15,19 @@ import sys from pathlib import Path +import numpy as np import pytest ROOT = Path(__file__).resolve().parents[1] EXAMPLES = ROOT / "examples" FASTAPI_DIR = EXAMPLES / "fastapi" REFLEX_DIR = EXAMPLES / "reflex" -REFLEX_APP = REFLEX_DIR / "xy_reflex_demo" / "xy_reflex_demo.py" +REFLEX_PACKAGE = REFLEX_DIR / "xy_reflex_demo" +REFLEX_APP = REFLEX_PACKAGE / "xy_reflex_demo.py" +REFLEX_DATA = REFLEX_PACKAGE / "data.py" +REFLEX_CHARTS = REFLEX_PACKAGE / "charts.py" +REFLEX_STATE = REFLEX_PACKAGE / "state.py" +REFLEX_COMPONENTS = REFLEX_PACKAGE / "components.py" def _load(path: Path, name: str): @@ -139,37 +146,228 @@ def test_fastapi_app_serves_live_charts_and_code() -> None: assert frame.buffers # the density grid rides raw, not base64 in JSON -# --- Reflex app structure (source text, no reflex import) ------------------- +# --- Reflex terminal data and chart builders (numpy + xy only) ------------- -def test_reflex_app_shows_every_linking_method_and_event() -> None: - src = REFLEX_APP.read_text(encoding="utf-8") +@pytest.fixture(scope="module") +def terminal_data_mod(): + sys.path.insert(0, str(REFLEX_DIR)) + return importlib.import_module("xy_reflex_demo.data") + + +@pytest.fixture(scope="module") +def terminal_charts_mod(): + sys.path.insert(0, str(REFLEX_DIR)) + return importlib.import_module("xy_reflex_demo.charts") + + +def _chart_payload(chart): + source = chart if hasattr(chart, "build_payload") else chart.figure() + return source.build_payload() + + +def test_terminal_market_data_is_deterministic_and_valid(terminal_data_mod) -> None: + symbols = terminal_data_mod.instrument_symbols() + assert len(symbols) >= 8 + assert len(symbols) == len(set(symbols)) + assert terminal_data_mod.SIMULATED_DATA_LABEL.startswith("SIMULATED DATA · AS OF ") + + first = terminal_data_mod.history("AAPL", resolution="1D", range_key="MAX") + second = terminal_data_mod.history("AAPL", resolution="1D", range_key="MAX") + assert 700 <= len(first.x) <= 800 # three years of weekday observations + for field in ("x", "open", "high", "low", "close", "volume"): + np.testing.assert_array_equal(getattr(first, field), getattr(second, field)) + assert np.isfinite(getattr(first, field)).all() + + assert np.all(first.high >= np.maximum(first.open, first.close)) + assert np.all(first.low <= np.minimum(first.open, first.close)) + assert np.all(first.high >= first.low) + assert np.all(first.volume >= 0) + assert str(first.x[-1]) == terminal_data_mod.AS_OF.isoformat() + + weekly = terminal_data_mod.history("AAPL", resolution="1W", range_key="1Y") + assert 45 <= len(weekly.x) <= 54 + assert np.all(weekly.high >= np.maximum(weekly.open, weekly.close)) + assert np.all(weekly.low <= np.minimum(weekly.open, weekly.close)) + + +def test_terminal_reference_data_and_risk_are_reproducible(terminal_data_mod) -> None: + assert terminal_data_mod.positions() == terminal_data_mod.positions() + assert terminal_data_mod.stories() == terminal_data_mod.stories() + assert terminal_data_mod.calendar_events() == terminal_data_mod.calendar_events() + assert terminal_data_mod.portfolio_summary() == terminal_data_mod.portfolio_summary() + + equity = terminal_data_mod.portfolio_equity("1Y") + returns = terminal_data_mod.portfolio_returns("1Y") + assert len(equity.dates) == len(equity.equity) == len(equity.pnl) + assert len(returns) == len(equity.equity) - 1 + assert np.isfinite(equity.equity).all() + assert np.isfinite(equity.pnl).all() + assert np.isfinite(returns).all() + drawdown = equity.equity / np.maximum.accumulate(equity.equity) - 1.0 + assert np.all(drawdown <= 0) + + symbols, correlation = terminal_data_mod.correlation_matrix() + assert correlation.shape == (len(symbols), len(symbols)) + np.testing.assert_allclose(correlation, correlation.T) + np.testing.assert_allclose(np.diag(correlation), 1.0) + assert np.isfinite(correlation).all() + + scenarios_95 = terminal_data_mod.stress_scenarios(95) + assert scenarios_95 == terminal_data_mod.stress_scenarios(95) + assert scenarios_95 != terminal_data_mod.stress_scenarios(99) + assert all(np.isfinite(scenario.pnl) for scenario in scenarios_95) + + +def test_terminal_security_chart_exercises_finance_layers(terminal_charts_mod) -> None: + chart = terminal_charts_mod.security_chart( + "AAPL", + range_key="1Y", + resolution="1D", + overlays=( + "SMA 20", + "EMA 50", + "Bollinger bands", + "VWAP", + "Anchored VWAP", + "Volume profile", + ), + oscillator="RSI", + drawing="XABCD", + ) + spec, _ = chart.build_payload() + assert spec["traces"][0]["kind"] == "candlestick" + assert {trace["name"] for trace in spec["traces"][1:]} >= { + "SMA 20", + "EMA 50", + "VWAP", + } + layer_kinds = {layer["kind"] for layer in spec["layers"]} + assert {"bollinger_bands", "anchored_vwap", "anchored_volume_profile", "rsi"} <= layer_kinds + assert "xabcd_pattern" in layer_kinds + + position_chart = terminal_charts_mod.security_chart( + "AAPL", range_key="6M", drawing="Long position" + ) + position_spec, _ = position_chart.build_payload() + position = next(layer for layer in position_spec["layers"] if layer["kind"] == "position") + assert position["anchors"]["entry"]["x"] < position["anchors"]["end"]["x"] + + +def test_terminal_landing_market_focus_uses_finance_chart(terminal_charts_mod) -> None: + chart = terminal_charts_mod.market_focus_chart() + spec, _ = chart.build_payload() + + assert spec["traces"][0]["kind"] == "candlestick" + assert spec["tools"]["active"] == "forecast" + layer_kinds = {layer["kind"] for layer in spec["layers"]} + assert { + "volume_bars", + "moving_average", + "anchored_vwap", + "anchored_volume_profile", + "macd", + "position_forecast", + } <= layer_kinds + + +def test_terminal_ticket_metrics_validate_long_and_short(terminal_charts_mod) -> None: + long_metrics = terminal_charts_mod.ticket_metrics( + { + "side": "Long", + "entry": 100.0, + "stop": 95.0, + "target": 115.0, + "account_size": 100_000.0, + "risk_percent": 1.0, + } + ) + assert long_metrics["valid"] is True + assert long_metrics["risk_amount"] == pytest.approx(1_000.0) + assert long_metrics["quantity"] == pytest.approx(200.0) + assert long_metrics["risk_reward"] == pytest.approx(3.0) + + invalid = terminal_charts_mod.ticket_metrics( + { + "side": "Short", + "entry": 100.0, + "stop": 95.0, + "target": 80.0, + "account_size": 100_000.0, + "risk_percent": 1.0, + } + ) + assert invalid["valid"] is False + assert invalid["error"] + + +def test_terminal_workspace_chart_builders_emit_specs(terminal_charts_mod) -> None: + charts = ( + terminal_charts_mod.market_focus_chart(), + terminal_charts_mod.market_heatmap_chart(), + terminal_charts_mod.yield_curve_chart(), + terminal_charts_mod.market_pulse_chart(), + terminal_charts_mod.portfolio_performance_chart(), + terminal_charts_mod.portfolio_allocation_chart(), + terminal_charts_mod.portfolio_contribution_chart(), + terminal_charts_mod.portfolio_exposure_chart(), + terminal_charts_mod.risk_distribution_chart(95), + terminal_charts_mod.risk_correlation_chart(), + terminal_charts_mod.risk_factor_chart(), + ) + for chart in charts: + spec, _ = _chart_payload(chart) + assert spec["traces"] or spec.get("layers") + assert spec.get("title") + + assert terminal_charts_mod.MARKET_FOCUS_CHART is not None + assert terminal_charts_mod.MARKET_HEATMAP_CHART is not None + assert terminal_charts_mod.YIELD_CURVE_CHART is not None + abbreviated = terminal_charts_mod.abbreviated_spec() + assert abbreviated["traces"][0]["kind"] == "candlestick" + assert abbreviated["layer_count"] >= 3 + + +# --- Reflex terminal structure (source text, no reflex import) ------------- + + +def _terminal_source() -> str: + return "\n".join( + path.read_text(encoding="utf-8") + for path in (REFLEX_APP, REFLEX_STATE, REFLEX_COMPONENTS, REFLEX_CHARTS) + ) + + +def test_reflex_terminal_preserves_every_linking_tier_and_event() -> None: + src = _terminal_source() required = [ - "@reflex_xy.figure", # live figure var - "reflex_xy.chart(", # the component - "reflex_xy.append(", # streaming - "reflex_xy.inline(", # inline() token tier - "sparkline_chart()", # static Chart tier passed directly - # the FastAPI 100M drilldown, served adapter-natively (§6); both apps - # honor the same point-count override for side-by-side comparison. - "def drilldown_chart", - "reflex_xy.inline(drilldown_chart())", - "XY_LIVE_POINTS", - "on_point_hover=", - "on_point_click=", - "on_select_end=", + "class TerminalState", + "@reflex_xy.figure", + "reflex_xy.chart(", + "reflex_xy.append(", + "reflex_xy.inline(", + "MARKET_FOCUS_CHART", + "MARKET_HEATMAP_CHART", + "on_hover=", "on_view_change=", - # click/hover are off by default, so on_point_click needs them enabled. - "interaction_config(hover=True, click=True)", - "inspect.getsource", # introspected code accordions - "def code_accordion", + "inspect.getsource", + "@rx.event(background=True)", + "async with self", ] for marker in required: assert marker in src, marker - # The showcase links charts natively, without iframe or postMessage bridges. + assert src.count("@rx.event(background=True)") == 1 + assert src.count("async def stream_quotes") == 1 + + for workspace in ("Markets", "Security", "Portfolio", "Risk", "News"): + assert workspace in src + for command in ("MKTS", "DES", "PORT", "RISK", "NEWS", "HELP"): + assert command in src + + # All linking tiers stay native to the adapter; no iframe/message bridge. assert "postMessage" not in src - assert "/charts/" not in src - assert "iframe" not in src.lower() + assert "rx.el.iframe" not in src + assert " None: @@ -178,27 +376,24 @@ def test_reflex_config_wires_the_xy_plugin() -> None: assert 'app_name="xy_reflex_demo"' in cfg -def test_reflex_app_introspection_and_composition(tmp_path, monkeypatch) -> None: +def test_reflex_terminal_imports_and_composes_in_temporary_cwd(tmp_path, monkeypatch) -> None: pytest.importorskip("reflex") pytest.importorskip("reflex_xy") - # A static chart compiles a payload asset into cwd/assets/xy; keep it in tmp. + # Direct Chart payloads compile into cwd/assets/xy; keep generated files out + # of the repository and prove the example does not depend on its launch cwd. monkeypatch.chdir(tmp_path) - # The §6 drilldown builds its columns at import; keep the test-time build - # cheap (same override the fastapi app test uses). - monkeypatch.setenv("XY_LIVE_POINTS", "50000") sys.path.insert(0, str(REFLEX_DIR)) - module = _load(REFLEX_APP, "xy_reflex_demo_under_test") - - # The Code accordion reads live source: figure vars unwrap to their builder, - # event handlers to their function — both include the decorator line. - assert "@reflex_xy.figure" in module._source(module.Demo.cloud) - assert "def cloud" in module._source(module.Demo.cloud) - assert "def on_view" in module._source(module.Demo.on_view) - # The page composes without error and mints inline() tokens at import. - assert module.ORBITS_TOKEN.startswith("xyin-") - assert module.DRILLDOWN_TOKEN.startswith("xyin-") - assert module.DRILLDOWN_POINTS == 50000 + module = importlib.import_module("xy_reflex_demo.xy_reflex_demo") + state = importlib.import_module("xy_reflex_demo.state") + components = importlib.import_module("xy_reflex_demo.components") + assert module.index() is not None + assert module.app is not None + assert state.TerminalState is not None + assert components.YIELD_CURVE_TOKEN.startswith("xyin-") + assert "@reflex_xy.figure" in components._source(state.TerminalState.security_figure) + assert "def stream_quotes" in components._source(state.TerminalState.stream_quotes) + assert (tmp_path / "assets" / "xy").is_dir() # --- retargeted browser smokes: import cleanly, pure helpers unit-tested ----- From 2295fb6e7e2057157fc6d39449a99c864596080a Mon Sep 17 00:00:00 2001 From: Alek Date: Sun, 2 Aug 2026 14:58:32 -0700 Subject: [PATCH 5/8] Polish finance terminal example --- examples/reflex/xy_reflex_demo/charts.py | 4 +- examples/reflex/xy_reflex_demo/components.py | 438 ++++++++++++++----- examples/reflex/xy_reflex_demo/state.py | 12 +- tests/test_example_apps.py | 18 + 4 files changed, 348 insertions(+), 124 deletions(-) diff --git a/examples/reflex/xy_reflex_demo/charts.py b/examples/reflex/xy_reflex_demo/charts.py index d027d879..143f36f5 100644 --- a/examples/reflex/xy_reflex_demo/charts.py +++ b/examples/reflex/xy_reflex_demo/charts.py @@ -393,7 +393,9 @@ def security_chart( last = float(values.close[-1]) if normalized_drawing in {"long_position", "short_position"}: selected_side = "long" if normalized_drawing == "long_position" else "short" - selected_ticket = dict(ticket or _default_ticket(meta.symbol, selected_side, last)) + selected_ticket = dict( + _default_ticket(meta.symbol, selected_side, last) if ticket is None else ticket + ) selected_ticket["side"] = selected_side selected_ticket.setdefault("symbol", meta.symbol) if ticket_metrics(selected_ticket)["valid"]: diff --git a/examples/reflex/xy_reflex_demo/components.py b/examples/reflex/xy_reflex_demo/components.py index 04a6eff6..bda2c204 100644 --- a/examples/reflex/xy_reflex_demo/components.py +++ b/examples/reflex/xy_reflex_demo/components.py @@ -80,6 +80,14 @@ def _signed_color(value: Any) -> str: return GREEN if _float(value) >= 0 else RED +def _compact_timestamp(value: Any, *, include_date: bool = False) -> str: + """Format the deterministic ISO timestamps for dense terminal rows.""" + text = str(value) + if len(text) >= 16 and text[10:11] == "T": + return f"{text[5:10]} {text[11:16]}" if include_date else text[11:16] + return text + + def terminal_button( label: Any, *, @@ -91,8 +99,12 @@ def terminal_button( return rx.button( label, on_click=on_click, - variant="ghost", + variant="surface", radius="none", + box_shadow="none", + margin="0", + flex_shrink="0", + white_space="nowrap", min_height="25px" if compact else "30px", padding="2px 7px" if compact else "4px 9px", border=f"1px solid {AMBER}" if active is True else f"1px solid {BORDER}", @@ -114,8 +126,12 @@ def state_button(label: str, value: Any, event: Any, *, compact: bool = True) -> return rx.button( label, on_click=event, - variant="ghost", + variant="surface", radius="none", + box_shadow="none", + margin="0", + flex_shrink="0", + white_space="nowrap", min_height="25px" if compact else "30px", padding="2px 7px" if compact else "4px 9px", border=f"1px solid {BORDER}", @@ -146,17 +162,30 @@ def panel( font_weight="800", letter_spacing="0.08em", text_transform="uppercase", + overflow="hidden", + text_overflow="ellipsis", + white_space="nowrap", ) ] if subtitle is not None: header_children.append( - rx.text(subtitle, color=MUTED, font_family=MONO, font_size="9px", margin_left="8px") + rx.text( + subtitle, + color=MUTED, + font_family=MONO, + font_size="9px", + margin_left="8px", + min_width="0", + overflow="hidden", + text_overflow="ellipsis", + white_space="nowrap", + ) ) props.setdefault("width", "100%") return rx.box( rx.hstack( - rx.hstack(*header_children, spacing="1", align="center"), - action or rx.box(), + rx.hstack(*header_children, spacing="1", align="center", min_width="0"), + rx.box(action or rx.box(), flex_shrink="0"), justify="between", align="center", min_height="28px", @@ -176,7 +205,16 @@ def panel( def metric(label: str, value: Any, *, color: str = TEXT, note: Any = None) -> rx.Component: return rx.box( rx.text(label, color=MUTED, font_family=MONO, font_size="9px", letter_spacing="0.06em"), - rx.text(value, color=color, font_family=MONO, font_size="16px", font_weight="750"), + rx.text( + value, + color=color, + font_family=MONO, + font_size="16px", + font_weight="750", + overflow="hidden", + text_overflow="ellipsis", + white_space="nowrap", + ), rx.text(note, color=MUTED, font_family=MONO, font_size="9px") if note is not None else rx.box(), @@ -228,7 +266,13 @@ def command_bar() -> rx.Component: return rx.vstack( rx.hstack( rx.text("XY", color=INK, background=AMBER, padding="3px 7px", font_weight="900"), - rx.text("COMMAND", color=AMBER, font_weight="800", font_size="10px"), + rx.text( + "COMMAND", + color=AMBER, + font_weight="800", + font_size="10px", + display=rx.breakpoints(initial="none", md="block"), + ), rx.input( value=TerminalState.command, on_change=TerminalState.set_command, @@ -243,10 +287,23 @@ def command_bar() -> rx.Component: font_family=MONO, font_size="12px", flex="1", - _placeholder={"color": "#696555"}, + min_width="0", + _placeholder={"color": MUTED}, _focus={"border_color": CYAN, "box_shadow": f"0 0 0 1px {CYAN}"}, ), terminal_button("GO", on_click=TerminalState.execute_command, compact=False), + rx.text( + "SIM DATA", + color=INK, + background=RED, + font_family=MONO, + font_size="9px", + font_weight="900", + padding="4px 7px", + white_space="nowrap", + display=rx.breakpoints(initial="block", sm="none"), + flex_shrink="0", + ), rx.text( "SIMULATED DATA", color=INK, @@ -256,10 +313,12 @@ def command_bar() -> rx.Component: font_weight="900", padding="4px 7px", white_space="nowrap", + display=rx.breakpoints(initial="none", sm="block"), + flex_shrink="0", ), width="100%", align="center", - spacing="2", + gap=rx.breakpoints(initial="4px", sm="8px"), ), rx.cond( TerminalState.help_visible, @@ -280,29 +339,6 @@ def command_bar() -> rx.Component: ) -def _quote_cell(row: Any) -> rx.Component: - symbol = str(_get(row, "symbol", "ticker")) - last = _get(row, "last", "price", "close", default=0.0) - change = _get( - row, - "change_percent", - "change_pct", - "percent_change", - "pct_change", - default=0.0, - ) - return rx.hstack( - rx.text(symbol, color=AMBER, font_weight="800"), - rx.text(f"{_float(last):,.2f}", color=TEXT), - rx.text(_percent(change), color=_signed_color(change)), - spacing="2", - align="center", - padding="2px 9px", - border_right=f"1px solid {BORDER}", - white_space="nowrap", - ) - - def ticker_tape() -> rx.Component: return rx.hstack( rx.foreach(TerminalState.tape_quotes, _live_quote_cell), @@ -354,13 +390,15 @@ def watchlist() -> rx.Component: rx.text(symbol, color=AMBER, font_weight="800"), rx.text(f"{_float(last):,.2f}", color=TEXT, text_align="right"), rx.text(_percent(change), color=_signed_color(change), text_align="right"), - columns="3", + grid_template_columns="minmax(64px, 1fr) minmax(70px, 1fr) 68px", width="100%", align_items="center", ), on_click=TerminalState.select_symbol(symbol), - variant="ghost", + variant="surface", radius="none", + box_shadow="none", + background="transparent", width="100%", min_height="27px", padding="3px 5px", @@ -406,10 +444,14 @@ def _movers_table() -> rx.Component: rx.grid( rx.text(symbol, color=AMBER, font_weight="800"), rx.text( - str(_get(row, "name", "label", default=symbol)), color=TEXT, overflow="hidden" + str(_get(row, "name", "label", default=symbol)), + color=TEXT, + overflow="hidden", + text_overflow="ellipsis", + white_space="nowrap", ), rx.text(_percent(change), color=_signed_color(change), text_align="right"), - columns="3", + grid_template_columns="64px minmax(0, 1fr) 62px", width="100%", padding="4px 2px", border_bottom="1px solid #1d1d19", @@ -424,10 +466,16 @@ def markets_workspace() -> rx.Component: return rx.vstack( panel( "Market focus · SPY", - reflex_xy.chart( - charts.MARKET_FOCUS_CHART, - height="520px", - id="market-finance-chart", + rx.box( + reflex_xy.chart( + charts.MARKET_FOCUS_CHART, + height=rx.breakpoints(initial="390px", sm="520px"), + min_width=rx.breakpoints(initial="520px", sm="100%"), + id="market-finance-chart", + ), + width="100%", + overflow_x="auto", + scrollbar_width="thin", ), subtitle="native FinanceChart · OHLCV · studies · tools", action=terminal_button( @@ -458,7 +506,7 @@ def markets_workspace() -> rx.Component: reflex_xy.chart(YIELD_CURVE_TOKEN, height="300px", id="yield-curve"), subtitle="inline() kernel token", ), - columns=rx.breakpoints(initial="1", lg="2"), + columns=rx.breakpoints(initial="1", md="2"), gap="8px", width="100%", ), @@ -501,7 +549,7 @@ def _selected_instrument_card() -> rx.Component: metric("BETA", f"{_float(_get(instrument, 'beta', default=0)):.2f}"), metric("VENUE", str(_get(instrument, "exchange", "venue", default="Global"))), metric("CCY", str(_get(instrument, "currency", default="USD"))), - columns=rx.breakpoints(initial="2", md="4"), + columns=rx.breakpoints(initial="1", sm="2", md="4"), gap="8px", width="100%", ), @@ -614,7 +662,7 @@ def paper_ticket() -> rx.Component: terminal_input( "TARGET", TerminalState.ticket_target, TerminalState.set_ticket_target ), - columns=rx.breakpoints(initial="2", md="4"), + columns=rx.breakpoints(initial="1", sm="2", md="4"), gap="7px", width="100%", ), @@ -626,7 +674,7 @@ def paper_ticket() -> rx.Component: metric("RISK AMOUNT", TerminalState.ticket_risk_amount), metric("POSITION SIZE", TerminalState.ticket_position_size), metric("REWARD / RISK", TerminalState.ticket_reward_risk), - columns=rx.breakpoints(initial="2", md="5"), + columns=rx.breakpoints(initial="1", sm="2", md="5"), gap="8px", width="100%", ), @@ -687,12 +735,18 @@ def security_workspace() -> rx.Component: rx.text("OHLCV ANALYSIS", color=TEXT), spacing="2", ), - reflex_xy.chart( - TerminalState.security_figure, - on_hover=TerminalState.on_chart_hover, - on_view_change=TerminalState.on_chart_view, - height="610px", - id="security-chart", + rx.box( + reflex_xy.chart( + TerminalState.security_figure, + on_hover=TerminalState.on_chart_hover, + on_view_change=TerminalState.on_chart_view, + height=rx.breakpoints(initial="430px", sm="610px"), + min_width=rx.breakpoints(initial="480px", sm="100%"), + id="security-chart", + ), + width="100%", + overflow_x="auto", + scrollbar_width="thin", ), subtitle=TerminalState.view_status, ), @@ -734,6 +788,7 @@ def _summary_metrics() -> rx.Component: def positions_table() -> rx.Component: + column_template = "70px 80px 120px 110px 85px" rows = [] for position in data.position_rows(): symbol = str(_get(position, "symbol", "ticker")) @@ -764,12 +819,14 @@ def positions_table() -> rx.Component: color=_signed_color(pnl), text_align="right", ), - columns="5", + grid_template_columns=column_template, width="100%", ), on_click=TerminalState.drilldown_position(symbol), - variant="ghost", + variant="surface", radius="none", + box_shadow="none", + background="transparent", min_height="30px", padding="4px 3px", width="100%", @@ -780,20 +837,24 @@ def positions_table() -> rx.Component: _hover={"background": "#231c08"}, ) ) - return rx.vstack( - rx.grid( - *[ - rx.text(label, color=MUTED, text_align="right" if index else "left") - for index, label in enumerate(("SYMBOL", "QTY", "MKT VALUE", "P&L", "RETURN")) - ], - columns="5", + return rx.box( + rx.vstack( + rx.grid( + *[ + rx.text(label, color=MUTED, text_align="right" if index else "left") + for index, label in enumerate(("SYMBOL", "QTY", "MKT VALUE", "P&L", "RETURN")) + ], + grid_template_columns=column_template, + width="100%", + padding="3px", + font_family=MONO, + font_size="9px", + ), + *rows, + spacing="0", + min_width="465px", width="100%", - padding="3px", - font_family=MONO, - font_size="9px", ), - *rows, - spacing="0", width="100%", overflow_x="auto", ) @@ -831,7 +892,7 @@ def portfolio_workspace() -> rx.Component: charts.portfolio_exposure_chart(), height="245px", id="portfolio-exposure" ), ), - columns=rx.breakpoints(initial="1", md="3"), + columns=rx.breakpoints(initial="1", md="2", lg="3"), gap="8px", width="100%", ), @@ -841,6 +902,7 @@ def portfolio_workspace() -> rx.Component: def scenario_table(confidence: float = 0.95) -> rx.Component: + column_template = "140px minmax(230px, 1fr) 110px" scenarios = list(data.stress_scenarios(confidence)) rows = [] for scenario in scenarios: @@ -854,13 +916,19 @@ def scenario_table(confidence: float = 0.95) -> rx.Component: str(_get(scenario, "shock", "description", default="Deterministic shock")), color=MUTED, ), - rx.text(_money(impact), color=_signed_color(impact), text_align="right"), - columns="3", + rx.text( + _money(impact), + color=_signed_color(impact), + text_align="right", + white_space="nowrap", + ), + grid_template_columns=column_template, width="100%", ), on_click=TerminalState.set_scenario(name), - variant="ghost", + variant="surface", radius="none", + box_shadow="none", width="100%", min_height="30px", color=TEXT, @@ -873,7 +941,11 @@ def scenario_table(confidence: float = 0.95) -> rx.Component: _hover={"background": "#231c08"}, ) ) - return rx.vstack(*rows, spacing="0", width="100%") + return rx.box( + rx.vstack(*rows, spacing="0", width="100%", min_width="520px"), + width="100%", + overflow_x="auto", + ) def risk_workspace() -> rx.Component: @@ -892,6 +964,7 @@ def risk_workspace() -> rx.Component: ], spacing="1", align="center", + wrap="wrap", ), ), rx.grid( @@ -906,7 +979,7 @@ def risk_workspace() -> rx.Component: charts.risk_correlation_chart(), height="310px", id="risk-correlation" ), ), - columns=rx.breakpoints(initial="1", lg="2"), + columns=rx.breakpoints(initial="1", md="2"), gap="8px", width="100%", ), @@ -924,7 +997,7 @@ def risk_workspace() -> rx.Component: ), subtitle="select scenario", ), - columns=rx.breakpoints(initial="1", lg="2"), + columns=rx.breakpoints(initial="1", md="2"), gap="8px", width="100%", ), @@ -943,7 +1016,12 @@ def _story_detail(story: Any) -> rx.Component: return rx.vstack( rx.hstack( rx.text(str(_get(story, "source", default="XY NEWS")), color=CYAN), - rx.text(str(_get(story, "timestamp", "time", default="--:--")), color=MUTED), + rx.text( + _compact_timestamp( + _get(story, "timestamp", "time", default="--:--"), include_date=True + ), + color=MUTED, + ), spacing="2", ), rx.heading(str(_get(story, "headline", "title")), color=TEXT, size="4", font_family=MONO), @@ -981,8 +1059,15 @@ def news_list() -> rx.Component: rows.append( rx.button( rx.grid( - rx.text(str(_get(story, "timestamp", "time", default="--:--")), color=CYAN), - rx.text(str(_get(story, "headline", "title")), color=TEXT, text_align="left"), + rx.text( + _compact_timestamp(_get(story, "timestamp", "time", default="--:--")), + color=CYAN, + ), + rx.text( + str(_get(story, "headline", "title")), + color=TEXT, + text_align="left", + ), rx.text( sentiment[:3].upper(), color=GREEN @@ -992,13 +1077,14 @@ def news_list() -> rx.Component: else AMBER, text_align="right", ), - columns="3", + grid_template_columns="58px minmax(0, 1fr) 40px", width="100%", align_items="start", ), on_click=TerminalState.select_story(story_id), - variant="ghost", + variant="surface", radius="none", + box_shadow="none", width="100%", height="auto", min_height="42px", @@ -1029,18 +1115,24 @@ def selected_story_detail() -> rx.Component: def calendar_table() -> rx.Component: + column_template = "82px 42px minmax(200px, 1fr) 72px 76px 76px" rows = [] for event in data.calendar_events(): importance = str(_get(event, "importance", "impact", default="Medium")) rows.append( rx.grid( - rx.text(str(_get(event, "time", "timestamp", default="--:--")), color=CYAN), + rx.text( + _compact_timestamp( + _get(event, "time", "timestamp", default="--:--"), include_date=True + ), + color=CYAN, + ), rx.text(str(_get(event, "country", "region", default="US")), color=AMBER), rx.text(str(_get(event, "event", "name", "title")), color=TEXT), rx.text(importance, color=RED if importance.lower() == "high" else AMBER), rx.text(str(_get(event, "consensus", "forecast", default="—")), text_align="right"), rx.text(str(_get(event, "prior", "previous", default="—")), text_align="right"), - columns="6", + grid_template_columns=column_template, width="100%", padding="5px 2px", border_bottom="1px solid #1d1d19", @@ -1048,7 +1140,25 @@ def calendar_table() -> rx.Component: font_size="10px", ) ) - return rx.vstack(*rows, spacing="0", width="100%", overflow_x="auto") + header = rx.grid( + *[ + rx.text(label, color=MUTED, text_align="right" if index >= 4 else "left") + for index, label in enumerate( + ("TIME", "REGION", "EVENT", "IMPACT", "CONSENSUS", "PRIOR") + ) + ], + grid_template_columns=column_template, + width="100%", + padding="3px 2px", + font_family=MONO, + font_size="9px", + border_bottom=f"1px solid {BORDER}", + ) + return rx.box( + rx.vstack(header, *rows, spacing="0", width="100%", min_width="650px"), + width="100%", + overflow_x="auto", + ) def news_workspace() -> rx.Component: @@ -1056,7 +1166,7 @@ def news_workspace() -> rx.Component: rx.grid( panel("Newswire", news_list(), subtitle="fictional headlines"), panel("Story detail", selected_story_detail()), - columns=rx.breakpoints(initial="1", lg="2"), + columns=rx.breakpoints(initial="1", md="2"), gap="8px", width="100%", ), @@ -1200,11 +1310,21 @@ def function_keys() -> rx.Component: spacing="1", ), on_click=TerminalState.choose_workspace(label), - variant="ghost", + variant="surface", radius="none", + box_shadow="none", + background=rx.cond(TerminalState.workspace == label, "#231c08", "transparent"), + margin="0", + flex_shrink="0", + white_space="nowrap", min_height="27px", padding="2px 6px", border_right=f"1px solid {BORDER}", + border_bottom=rx.cond( + TerminalState.workspace == label, + f"2px solid {AMBER}", + "2px solid transparent", + ), font_family=MONO, font_size="9px", _hover={"background": "#231c08"}, @@ -1324,66 +1444,142 @@ def developer_drawer() -> rx.Component: def terminal_shell() -> rx.Component: return rx.box( rx.box( - rx.hstack( - rx.vstack( - rx.hstack( - rx.text( - "XY TERMINAL", - color=AMBER, - font_family=MONO, - font_size="18px", - font_weight="950", + rx.box( + rx.hstack( + rx.vstack( + rx.hstack( + rx.text( + "XY TERMINAL", + color=AMBER, + font_family=MONO, + font_size="18px", + font_weight="950", + white_space="nowrap", + ), + rx.text( + "MULTI-ASSET ANALYTICS", + color=MUTED, + font_family=MONO, + font_size="9px", + white_space="nowrap", + display=rx.breakpoints(initial="none", sm="block"), + ), + spacing="2", + align="center", ), rx.text( - "MULTI-ASSET ANALYTICS", color=MUTED, font_family=MONO, font_size="9px" + "INDEPENDENT TERMINAL-STYLE DEMO · NO EXTERNAL MARKET FEED", + color=MUTED, + font_family=MONO, + font_size="8px", + white_space="nowrap", + display=rx.breakpoints(initial="none", md="block"), ), - spacing="2", - align="center", + spacing="0", + align="start", + min_width="0", ), + rx.spacer(), rx.text( - "INDEPENDENT TERMINAL-STYLE DEMO · NO EXTERNAL MARKET FEED", - color=MUTED, + f"AS OF {AS_OF}", + color=TEXT, font_family=MONO, - font_size="8px", + font_size="9px", + white_space="nowrap", + flex_shrink="0", ), - spacing="0", - align="start", + align="center", + width="100%", + padding="6px 8px", ), - rx.spacer(), - rx.text(f"AS OF {AS_OF}", color=TEXT, font_family=MONO, font_size="9px"), - align="center", - width="100%", - padding="6px 8px", + command_bar(), + padding="0 8px 7px", + background="#090a0a", ), - command_bar(), - padding="0 8px 7px", - background="#090a0a", + ticker_tape(), + position="sticky", + top="0", + z_index="40", + background=INK, ), - ticker_tape(), rx.grid( - rx.box(watchlist(), min_width="0"), - rx.box(workspace(), min_width="0", overflow="hidden"), - rx.box(context_rail(), min_width="0"), + rx.box( + rx.vstack(watchlist(), context_rail(), spacing="2", width="100%"), + min_width="0", + order=rx.breakpoints(initial="2", sm="1"), + display=rx.breakpoints(initial="block", lg="none"), + position=rx.breakpoints(initial="static", sm="sticky"), + top="112px", + max_height=rx.breakpoints(initial="none", sm="calc(100vh - 160px)"), + overflow_y=rx.breakpoints(initial="visible", sm="auto"), + scrollbar_width="thin", + ), + rx.box( + watchlist(), + min_width="0", + order="1", + display=rx.breakpoints(initial="none", lg="block"), + position="sticky", + top="112px", + ), + rx.box( + workspace(), + min_width="0", + overflow="hidden", + order=rx.breakpoints(initial="1", sm="2"), + ), + rx.box( + context_rail(), + min_width="0", + order="3", + display=rx.breakpoints(initial="none", lg="block"), + position="sticky", + top="112px", + ), grid_template_columns=rx.breakpoints( initial="minmax(0, 1fr)", + sm="180px minmax(0, 1fr)", lg="210px minmax(0, 1fr) 225px", ), gap="8px", width="100%", padding="8px", align_items="start", + flex="1", ), - function_keys(), - rx.hstack( - rx.text(TerminalState.command_status, color=CYAN), - rx.spacer(), - rx.text("● LOCAL", color=GREEN), - rx.text("NO API KEY", color=MUTED), - width="100%", - padding="3px 8px", + rx.box( + function_keys(), + rx.hstack( + rx.text( + TerminalState.command_status, + color=CYAN, + min_width="0", + overflow="hidden", + text_overflow="ellipsis", + white_space="nowrap", + ), + rx.spacer(), + rx.hstack( + rx.text("● LOCAL", color=GREEN, white_space="nowrap"), + rx.text( + "NO API KEY", + color=MUTED, + white_space="nowrap", + display=rx.breakpoints(initial="none", sm="block"), + ), + spacing="2", + flex_shrink="0", + ), + width="100%", + padding="3px 8px", + background="#080909", + font_family=MONO, + font_size="9px", + ), + position="sticky", + bottom="0", + z_index="40", background="#080909", - font_family=MONO, - font_size="9px", ), developer_drawer(), background=INK, @@ -1391,6 +1587,8 @@ def terminal_shell() -> rx.Component: min_height="100vh", width="100%", font_family=MONO, + display="flex", + flex_direction="column", ) diff --git a/examples/reflex/xy_reflex_demo/state.py b/examples/reflex/xy_reflex_demo/state.py index e10b75d7..a9b4b088 100644 --- a/examples/reflex/xy_reflex_demo/state.py +++ b/examples/reflex/xy_reflex_demo/state.py @@ -111,6 +111,7 @@ class TerminalState(rx.State): streaming: bool = False _stream_step: int = 35 + _stream_generation: int = 0 tape_quotes: list[dict[str, str]] = _tape_rows(35) ticket_side: str = "Long" @@ -193,7 +194,6 @@ def state_snapshot(self) -> str: @reflex_xy.figure def security_figure(self): - ticket = self._raw_ticket() if self._ticket_result().get("valid") else None return charts.security_chart( self.selected_symbol, range_key=self.range_key, @@ -201,7 +201,7 @@ def security_figure(self): overlays=tuple(self.overlays), oscillator=self.oscillator, drawing=self.drawing, - ticket=ticket, + ticket=self._raw_ticket(), ) @reflex_xy.figure @@ -398,6 +398,8 @@ def on_chart_view(self, event: reflex_xy.ViewChangeEvent): async def stream_quotes(self): """Start/stop the single market-pulse producer for this state token.""" async with self: + self._stream_generation += 1 + generation = self._stream_generation if self.streaming: self.streaming = False return @@ -405,7 +407,11 @@ async def stream_quotes(self): token = self.market_pulse while True: async with self: - if not self.streaming or token != self.market_pulse: + if ( + not self.streaming + or generation != self._stream_generation + or token != self.market_pulse + ): break self._stream_step += 1 step = self._stream_step diff --git a/tests/test_example_apps.py b/tests/test_example_apps.py index e84ef1c2..ac3fd283 100644 --- a/tests/test_example_apps.py +++ b/tests/test_example_apps.py @@ -253,6 +253,22 @@ def test_terminal_security_chart_exercises_finance_layers(terminal_charts_mod) - position = next(layer for layer in position_spec["layers"] if layer["kind"] == "position") assert position["anchors"]["entry"]["x"] < position["anchors"]["end"]["x"] + invalid_position_chart = terminal_charts_mod.security_chart( + "AAPL", + range_key="6M", + drawing="Long position", + ticket={ + "side": "Long", + "entry": 100.0, + "stop": 105.0, + "target": 115.0, + "account_size": 100_000.0, + "risk_percent": 1.0, + }, + ) + invalid_position_spec, _ = invalid_position_chart.build_payload() + assert "position" not in {layer["kind"] for layer in invalid_position_spec["layers"]} + def test_terminal_landing_market_focus_uses_finance_chart(terminal_charts_mod) -> None: chart = terminal_charts_mod.market_focus_chart() @@ -358,6 +374,8 @@ def test_reflex_terminal_preserves_every_linking_tier_and_event() -> None: assert marker in src, marker assert src.count("@rx.event(background=True)") == 1 assert src.count("async def stream_quotes") == 1 + assert "_stream_generation" in src + assert "generation != self._stream_generation" in src for workspace in ("Markets", "Security", "Portfolio", "Risk", "News"): assert workspace in src From 65552d5835da5e97a5e6dc060199ef0bb7c04577 Mon Sep 17 00:00:00 2001 From: Alek Date: Sun, 2 Aug 2026 15:06:16 -0700 Subject: [PATCH 6/8] Use dark toolbar chrome in terminal example --- examples/reflex/rxconfig.py | 10 +++++++++- examples/reflex/xy_reflex_demo/components.py | 1 + tests/test_example_apps.py | 6 ++++++ tests/test_ui_issue_regressions.py | 12 ++++++++++++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/examples/reflex/rxconfig.py b/examples/reflex/rxconfig.py index 53bf932d..32eb73cf 100644 --- a/examples/reflex/rxconfig.py +++ b/examples/reflex/rxconfig.py @@ -5,7 +5,15 @@ config = rx.Config( app_name="xy_reflex_demo", plugins=[ - rx.plugins.RadixThemesPlugin(), + rx.plugins.RadixThemesPlugin( + theme=rx.theme( + color_mode="dark", + accent_color="amber", + gray_color="olive", + panel_background="solid", + radius="none", + ) + ), rx.plugins.SitemapPlugin(), reflex_xy.XYPlugin(), ], diff --git a/examples/reflex/xy_reflex_demo/components.py b/examples/reflex/xy_reflex_demo/components.py index bda2c204..0f156b42 100644 --- a/examples/reflex/xy_reflex_demo/components.py +++ b/examples/reflex/xy_reflex_demo/components.py @@ -1589,6 +1589,7 @@ def terminal_shell() -> rx.Component: font_family=MONO, display="flex", flex_direction="column", + class_name="dark", ) diff --git a/tests/test_example_apps.py b/tests/test_example_apps.py index ac3fd283..576b4967 100644 --- a/tests/test_example_apps.py +++ b/tests/test_example_apps.py @@ -394,6 +394,11 @@ def test_reflex_config_wires_the_xy_plugin() -> None: assert 'app_name="xy_reflex_demo"' in cfg +def test_reflex_config_declares_dark_theme() -> None: + config_source = (REFLEX_DIR / "rxconfig.py").read_text(encoding="utf-8") + assert 'color_mode="dark"' in config_source + + def test_reflex_terminal_imports_and_composes_in_temporary_cwd(tmp_path, monkeypatch) -> None: pytest.importorskip("reflex") pytest.importorskip("reflex_xy") @@ -408,6 +413,7 @@ def test_reflex_terminal_imports_and_composes_in_temporary_cwd(tmp_path, monkeyp assert module.index() is not None assert module.app is not None assert state.TerminalState is not None + assert components.terminal_shell().class_name == "dark" assert components.YIELD_CURVE_TOKEN.startswith("xyin-") assert "@reflex_xy.figure" in components._source(state.TerminalState.security_figure) assert "def stream_quotes" in components._source(state.TerminalState.stream_quotes) diff --git a/tests/test_ui_issue_regressions.py b/tests/test_ui_issue_regressions.py index 22b58d06..c1080b55 100644 --- a/tests/test_ui_issue_regressions.py +++ b/tests/test_ui_issue_regressions.py @@ -433,6 +433,14 @@ def test_modebar_active_button_uses_dark_active_color(tmp_path: Path) -> None: ); const darkActiveBackground = getComputedStyle(active).backgroundColor; const darkBarBackground = getComputedStyle(bar).backgroundColor; + const exportTrigger = view.root.querySelector( + 'button[data-xy-modebar-export-trigger]' + ); + exportTrigger.click(); + const exportMenu = view.root.querySelector('[data-xy-modebar-export-menu]'); + const exportItem = exportMenu.querySelector('[data-xy-modebar-menu-item]'); + const darkMenuBackground = getComputedStyle(exportMenu).backgroundColor; + const darkMenuText = getComputedStyle(exportItem).color; active.focus(); const darkFocusShadow = getComputedStyle(active).boxShadow; // An app that themes focus once with --chart-focus keeps a single ring @@ -446,6 +454,8 @@ def test_modebar_active_button_uses_dark_active_color(tmp_path: Path) -> None: document.body.setAttribute("data-xy-issue-probe", JSON.stringify({ darkActiveBackground, darkBarBackground, + darkMenuBackground, + darkMenuText, darkFocusShadow, inheritedFocusShadow, customActiveBackground, @@ -458,6 +468,8 @@ def test_modebar_active_button_uses_dark_active_color(tmp_path: Path) -> None: assert result["darkActiveBackground"] == "rgb(18, 20, 23)", result assert result["darkBarBackground"] == "rgb(27, 29, 32)", result + assert result["darkMenuBackground"] == "rgb(27, 29, 32)", result + assert result["darkMenuText"] == "rgb(173, 180, 191)", result assert "rgb(226, 229, 233)" in result["darkFocusShadow"], result assert "rgb(0, 0, 255)" in result["inheritedFocusShadow"], result assert result["customActiveBackground"] == "rgb(255, 0, 255)", result From b87bf0ca6e0ee613e7ac08626ec4b63d418a5a1e Mon Sep 17 00:00:00 2001 From: Alek Date: Tue, 4 Aug 2026 11:55:36 -0700 Subject: [PATCH 7/8] Remove example app changes from finance PR --- docs/quant-finance-roadmap.md | 2 - examples/echarts.ipynb | 116 -- examples/reflex/.gitignore | 1 - examples/reflex/README.md | 169 +- examples/reflex/rxconfig.py | 9 - examples/reflex/xy_reflex_demo/__init__.py | 2 +- examples/reflex/xy_reflex_demo/charts.py | 722 -------- examples/reflex/xy_reflex_demo/components.py | 1611 ----------------- examples/reflex/xy_reflex_demo/data.py | 927 ---------- examples/reflex/xy_reflex_demo/state.py | 439 ----- .../reflex/xy_reflex_demo/xy_reflex_demo.py | 721 +++++++- tests/test_example_apps.py | 299 +-- 12 files changed, 822 insertions(+), 4196 deletions(-) delete mode 100644 examples/echarts.ipynb delete mode 100644 examples/reflex/xy_reflex_demo/charts.py delete mode 100644 examples/reflex/xy_reflex_demo/components.py delete mode 100644 examples/reflex/xy_reflex_demo/data.py delete mode 100644 examples/reflex/xy_reflex_demo/state.py diff --git a/docs/quant-finance-roadmap.md b/docs/quant-finance-roadmap.md index 1fcd2479..c4fd9864 100644 --- a/docs/quant-finance-roadmap.md +++ b/docs/quant-finance-roadmap.md @@ -483,5 +483,3 @@ This roadmap is complete when: studies and overlays are either screen-bounded, incrementally computed, or precomputed. - Native and NumPy fallback calculations match for all finance kernels. -- The example Reflex app contains a finance-workstation page exercising the - major tools side by side. diff --git a/examples/echarts.ipynb b/examples/echarts.ipynb deleted file mode 100644 index 1e13cc83..00000000 --- a/examples/echarts.ipynb +++ /dev/null @@ -1,116 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "echarts-title", - "metadata": {}, - "source": [ - "# ECharts Notebook Smoke Test\n", - "\n", - "This notebook verifies that Apache ECharts can render from a Jupyter notebook using `pyecharts`. It intentionally stays independent of `fastcharts` so we can use it as a clean comparison/control chart when testing notebook behavior." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "echarts-imports", - "metadata": {}, - "outputs": [], - "source": [ - "try:\n", - " from IPython.display import display\n", - " from pyecharts import options as opts\n", - " from pyecharts.charts import Bar, Line\n", - " from pyecharts.globals import CurrentConfig\n", - "except ModuleNotFoundError as exc:\n", - " raise RuntimeError(\n", - " \"Install notebook example dependencies with: \"\n", - " \"uv pip install nbformat nbclient nbconvert pyecharts\"\n", - " ) from exc\n", - "\n", - "# The default pyecharts asset host can be sensitive to certificate/date\n", - "# issues in automated browsers. Use a version-pinned public ECharts build.\n", - "CurrentConfig.ONLINE_HOST = \"https://cdn.jsdelivr.net/npm/echarts@5.6.0/dist/\"\n", - "\n", - "print(\"pyecharts import ok\")" - ] - }, - { - "cell_type": "markdown", - "id": "echarts-chart-heading", - "metadata": {}, - "source": [ - "## Combined Bar + Line Chart\n", - "\n", - "The chart below exercises the ECharts runtime, tooltip config, dual y-axes, and an overlapped series." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "echarts-chart", - "metadata": {}, - "outputs": [], - "source": [ - "months = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\"]\n", - "revenue = [42, 58, 61, 73, 86, 97]\n", - "latency_ms = [38, 34, 31, 29, 25, 22]\n", - "\n", - "bar = (\n", - " Bar(init_opts=opts.InitOpts(width=\"900px\", height=\"420px\", renderer=\"canvas\"))\n", - " .add_xaxis(months)\n", - " .add_yaxis(\"Revenue\", revenue, color=\"#3b82f6\")\n", - " .extend_axis(\n", - " yaxis=opts.AxisOpts(\n", - " name=\"Latency ms\",\n", - " position=\"right\",\n", - " axislabel_opts=opts.LabelOpts(formatter=\"{value} ms\"),\n", - " )\n", - " )\n", - " .set_global_opts(\n", - " title_opts=opts.TitleOpts(title=\"ECharts notebook smoke test\"),\n", - " tooltip_opts=opts.TooltipOpts(trigger=\"axis\"),\n", - " legend_opts=opts.LegendOpts(pos_top=\"8%\"),\n", - " xaxis_opts=opts.AxisOpts(name=\"Month\"),\n", - " yaxis_opts=opts.AxisOpts(name=\"Revenue\"),\n", - " )\n", - ")\n", - "\n", - "line = Line().add_xaxis(months).add_yaxis(\"Latency ms\", latency_ms, yaxis_index=1, color=\"#ef4444\")\n", - "\n", - "chart = bar.overlap(line)\n", - "notebook_chart = chart.render_notebook()\n", - "html = notebook_chart.data\n", - "\n", - "assert \"cdn.jsdelivr.net/npm/echarts@5.6.0/dist/echarts.min\" in html\n", - "assert \"require(['echarts']\" in html\n", - "assert \"echarts.init\" in html\n", - "assert \"setOption\" in html\n", - "assert \"ECharts notebook smoke test\" in html\n", - "print(\"ECharts embed smoke test passed\")\n", - "\n", - "display(notebook_chart)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/reflex/.gitignore b/examples/reflex/.gitignore index dd2a3fdf..fc4eef99 100644 --- a/examples/reflex/.gitignore +++ b/examples/reflex/.gitignore @@ -8,6 +8,5 @@ __pycache__/ .venv/ uv.lock reflex.lock/ -reflex.log assets/external/ assets/xy/ diff --git a/examples/reflex/README.md b/examples/reflex/README.md index 07d71665..0fcd1ee9 100644 --- a/examples/reflex/README.md +++ b/examples/reflex/README.md @@ -1,113 +1,80 @@ -# XY TERMINAL - -XY TERMINAL is a dense, professional-market workstation built entirely in -[Reflex](https://reflex.dev) with the `xy[reflex]` integration. It demonstrates -finance charts, state-driven figures, fixed and streamed data, and semantic -chart events in one responsive page. - -> **SIMULATED DATA** — every quote, price series, position, news story, -> economic event, and risk result in this example is fictional and generated -> locally from fixed seeds. The app does not contact a market-data service, -> submit orders, or require an API key. It is an interface and charting demo, -> not investment advice. - -The black-and-amber visual language is inspired by professional market -terminals, but the app does not use third-party brand names, logos, assets, or data. - -## Workspaces - -- **Markets (`MKTS`)** — a landing-page SPY `FinanceChart` with native OHLCV, - studies, oscillator, projection, and finance tools, plus cross-asset quotes, - movers, breadth, a market heatmap, the yield curve, and a live pulse fed - through `reflex_xy.append()`. -- **Security (`DES `)** — daily or weekly OHLCV, range controls, - overlays, oscillator panes, finance drawing presets, key statistics, - related stories, and a paper-only position-risk ticket. -- **Portfolio (`PORT`)** — deterministic positions, NAV and P&L, equity and - drawdown, allocation, contribution, and exposure. Choosing a position opens - its Security workspace. -- **Risk (`RISK`)** — return distribution with VaR/CVaR, correlations, factor - exposure, confidence controls, and deterministic stress scenarios. -- **News (`NEWS`)** — simulated stories with sentiment and impact metadata, - story detail, and a fictional economic calendar. - -The persistent shell also includes a ticker tape, watchlist, context rail, -function-key navigation, status line, and a developer drawer. The drawer shows -live Python source, a compact Reflex-state snapshot, and an abbreviated XY -chart/layer specification. - -## Commands - -Type a command in the top command bar and press Enter: - -| Command | Result | -| --- | --- | -| `MKTS` | Open Markets | -| `DES AAPL` | Open the Security workspace for a known symbol | -| `PORT` | Open Portfolio | -| `RISK` | Open Risk | -| `NEWS` | Open News | -| `HELP` | Show the command reference | - -Commands and symbols are case-insensitive. Unknown input stays in the app and -produces an inline status message. +# XY Reflex showcase + +A [Reflex](https://reflex.dev) app built with the `xy[reflex]` integration. +One page walks through the ways to link chart data into a Reflex app, and each +section carries a **Code** accordion showing its source via +`inspect.getsource`. + +Chart data rides the app's own websocket as a second socket.io namespace of +binary columns; Reflex state holds only a token string per chart. + +## What it shows + +1. **Live figure var + events** — a 1M-point drillable scatter from an + `@reflex_xy.figure` method, with `on_point_hover` / `on_point_click` / + `on_select_end` handlers. +2. **A chart driven by state vars** — a histogram whose bin count is a slider + and whose data is cross-filtered by the selection above; changing either + recomputes and re-publishes the figure under a stable token. +3. **A dynamically updating chart** — a line grown by a background task via + `reflex_xy.append`. +4. **Data computed from `on_view_change`** — pan/zoom an overview and a detail + histogram recomputes from the points in the reported window. +5. **Fixed data, two ways** — a `xy.Chart` passed straight to `reflex_xy.chart` + (static payload tier) and a `reflex_xy.inline` token (fixed data served + through the kernel). +6. **The 100M drilldown, adapter-native** — the live drilldown scatter + from [`examples/fastapi`](../fastapi) (identical seed-11 data and mark + config, a density surface that drills into exact points on zoom) as a + single `reflex_xy.inline` token. The FastAPI app hand-rolls its transport + for this chart (a Starlette endpoint plus an HTTP comm bridge); here the + adapter's websocket namespace and the kernel's density tiers do all of it, + so behavioral differences between the two apps isolate what that custom + code adds. ## Run -From this directory: - ```bash cd examples/reflex uv run reflex run ``` -`uv run` resolves this directory's [`pyproject.toml`](pyproject.toml), including -the editable local `xy[reflex]` package. Open the URL printed by Reflex -(normally ). No environment variables or external -services are required. - -## Architecture - -The `xy_reflex_demo` package is split by responsibility: - -- `data.py` defines typed instrument, quote, position, story, calendar, and - scenario models. Cached NumPy generators create three years of seeded daily - OHLCV as of the fixed date displayed in the app. -- `charts.py` contains pure data transforms and chart builders for all five - workspaces, including finance studies and drawings. -- `state.py` keeps only small UI selections and inputs in Reflex state. It - owns command routing, semantic chart events, paper-ticket validation, and - one guarded background quote loop. -- `components.py` composes the persistent terminal shell and responsive - workspace views; the package entry point registers the single page. - -State-dependent Security, Portfolio, and Risk charts use -`@reflex_xy.figure`. The first Markets panel is a direct, fixed-data -`xy.FinanceChart`, so the new finance surface is visible immediately rather -than only after a Security drilldown. Other fixed views exercise a direct -`xy.Chart` and the kernel-backed `reflex_xy.inline()` tier. The live pulse -starts with a figure token and receives compact points through -`reflex_xy.append()`. Hover and view-change events are handled as ordinary -Reflex events; there is no iframe or `postMessage` bridge. +`uv run` resolves this directory's [`pyproject.toml`](pyproject.toml) +(`xy[reflex]`) into a local environment. Open the URL Reflex prints (usually +). Zoom into the cloud to drill density into exact +points; box-select to cross-filter the histogram; press **go live** to stream. -The adapter is enabled by `reflex_xy.XYPlugin()` in -[`rxconfig.py`](rxconfig.py). Chart payloads travel through the app's XY -websocket namespace while Reflex state retains only lightweight selections -and token strings. - -## Paper ticket - -The Security ticket accepts side, entry, stop, target, account size, and risk -percentage. A valid setup updates the long/short chart overlay and displays -risk, quantity, and reward/risk metrics. Invalid ordering is explained inline -and suppresses the overlay. The button does not place or simulate an order. - -## Checks - -From the repository root, the focused test covers deterministic data, OHLC -invariants, portfolio/risk calculations, representative chart specs, linking -tiers, semantic events, and app composition: +`XY_LIVE_POINTS` sets §6's point count — the same override the FastAPI app +honors, so both apps build the identical dataset at any size. Unlike the +FastAPI app (lazy, on first use) the columns are built at import, because +`inline()` registers at module scope; the default 100M costs a few gigabytes +of RAM and some startup seconds, so dial it down on small machines: ```bash -uv run pytest tests/test_example_apps.py -q +XY_LIVE_POINTS=1000000 uv run reflex run ``` + +The adapter is wired in one line — `plugins=[reflex_xy.XYPlugin()]` in +[`rxconfig.py`](rxconfig.py). + +## Interaction contract checks + +Section 1's badges are event counters, and its click/select handlers +deliberately republish the cloud behind its stable token (the title's +`handler revision`). Together they make the wrapper's restore contract +manually verifiable: + +1. Box-select a large area. The `select` readout shows the exact total, the + bounded JSON row count, and `truncated`; the §2 histogram cross-filters. + The cloud must keep both its viewport and its selection highlight across + the republish, and the selection counter must increment exactly once. +2. Zoom until density drills into exact points, then click one. The `click` + readout shows its canonical row ID, f64 data coordinates, and active + keyboard modifiers; the click counter must increment exactly once. +3. Focus a point and press Enter or Space. Keyboard activation must produce + the same click readout contract as pointer activation. +4. Clear the selection. The histogram returns to all points and the select + counter increments exactly once again. + +A runaway counter or a viewport/selection reset after any of these reveals a +republish feedback loop or a restore regression. diff --git a/examples/reflex/rxconfig.py b/examples/reflex/rxconfig.py index 32eb73cf..a4952b59 100644 --- a/examples/reflex/rxconfig.py +++ b/examples/reflex/rxconfig.py @@ -5,15 +5,6 @@ config = rx.Config( app_name="xy_reflex_demo", plugins=[ - rx.plugins.RadixThemesPlugin( - theme=rx.theme( - color_mode="dark", - accent_color="amber", - gray_color="olive", - panel_background="solid", - radius="none", - ) - ), rx.plugins.SitemapPlugin(), reflex_xy.XYPlugin(), ], diff --git a/examples/reflex/xy_reflex_demo/__init__.py b/examples/reflex/xy_reflex_demo/__init__.py index 1611fa81..82088d98 100644 --- a/examples/reflex/xy_reflex_demo/__init__.py +++ b/examples/reflex/xy_reflex_demo/__init__.py @@ -1 +1 @@ -"""Deterministic terminal example data, charts, and Reflex application.""" +"""XY Reflex showcase app.""" diff --git a/examples/reflex/xy_reflex_demo/charts.py b/examples/reflex/xy_reflex_demo/charts.py deleted file mode 100644 index 143f36f5..00000000 --- a/examples/reflex/xy_reflex_demo/charts.py +++ /dev/null @@ -1,722 +0,0 @@ -"""Pure :mod:`xy` chart builders for the simulated terminal example. - -The Reflex app wraps state-dependent builders with ``@reflex_xy.figure`` and -chooses whether fixed charts travel as direct payloads or ``inline()`` tokens. -Keeping this module framework-neutral makes the data and finance composition -cheap to test without starting a server. -""" - -from __future__ import annotations - -import math -from collections.abc import Iterable, Mapping -from typing import Any - -import numpy as np - -import xy - -from . import data - -_BG = "#050505" -_PLOT_BG = "#090806" -_AMBER = "#f6c453" -_AMBER_DIM = "#9b7428" -_GRID = "#30240d" -_GREEN = "#35d07f" -_RED = "#ff5a5f" -_BLUE = "#5aa9ff" -_VIOLET = "#b892ff" - -_FINANCE_STYLE = { - "background": _BG, - "color": _AMBER, - "--chart-bg": _PLOT_BG, - "--chart-grid": _GRID, - "--chart-axis": _AMBER_DIM, - "--chart-text": _AMBER, - "--chart-crosshair": "#ffe19a", - "--chart-tooltip-bg": "#17130a", - "--chart-tooltip-text": "#fff1c2", -} - - -def _theme() -> xy.Theme: - return xy.theme( - background=_BG, - plot_background=_PLOT_BG, - grid_color=_GRID, - axis_color=_AMBER_DIM, - text_color=_AMBER, - crosshair_color="#ffe19a", - tooltip_bg="#17130a", - tooltip_text="#fff1c2", - palette=[_AMBER, _GREEN, _BLUE, _VIOLET, _RED, "#70d6ff"], - ) - - -def market_heatmap_chart() -> xy.Chart: - symbols, ranges, values = data.market_heatmap_data() - bound = max(1.0, float(np.max(np.abs(values)))) - return xy.heatmap_chart( - xy.heatmap( - values, - x=symbols, - y=ranges, - name="return %", - colormap="spectral", - domain=(-bound, bound), - ), - xy.x_axis(tick_label_angle=-34, tick_label_anchor="end"), - xy.y_axis(label="window"), - xy.colorbar(title="return %"), - _theme(), - title="CROSS-ASSET RETURN MAP · SIMULATED", - width="100%", - height=260, - ) - - -def yield_curve_chart() -> xy.Chart: - tenors, years, rates = data.yield_curve() - return xy.line_chart( - xy.line(years, rates, name="Treasury", color=_AMBER, width=2.0), - xy.scatter(years, rates, name="tenors", color=_GREEN, size=7.0, opacity=0.95), - xy.x_axis(label="maturity", tick_values=years, tick_labels=tenors), - xy.y_axis(label="yield (%)", side="right", format=".2f"), - xy.legend(show=False), - _theme(), - title="SIMULATED U.S. TREASURY CURVE", - width="100%", - height=260, - ) - - -def market_pulse_chart() -> xy.Chart: - x, values = data.pulse_seed() - return xy.line_chart( - xy.line(x, values, name="pulse", color=_AMBER, width=1.8), - xy.x_axis(show=False), - xy.y_axis(label="normalized", side="right", tick_count=4), - xy.legend(show=False), - _theme(), - title="LIVE MARKET PULSE · SIMULATED", - width="100%", - height=190, - padding=(22, 44, 28, 14), - ) - - -def market_focus_chart() -> Any: - """Build the landing-page finance chart from the native finance surface. - - The Markets workspace is the first screen a visitor sees, so it should not - make the flagship ``FinanceChart`` look like a hidden Security-only detail. - This fixed SPY view deliberately exercises the same candlestick, study, - oscillator, projection, and finance-tool payload used by the state-backed - Security workspace. - """ - - return security_chart( - "SPY", - range_key="6M", - resolution="1D", - overlays=("SMA 20", "Anchored VWAP", "Volume Profile"), - oscillator="MACD", - drawing="Forecast", - ) - - -def _canonical(value: str) -> str: - return "".join(character for character in value.lower() if character.isalnum()) - - -_OVERLAYS = { - "sma20": "sma20", - "ema50": "ema50", - "bollinger": "bollinger", - "bollingerbands": "bollinger", - "vwap": "vwap", - "anchoredvwap": "anchored_vwap", - "avwap": "anchored_vwap", - "volumeprofile": "volume_profile", - "anchoredvolumeprofile": "volume_profile", -} -_OSCILLATORS = { - "": "none", - "none": "none", - "rsi": "rsi", - "macd": "macd", - "stochastic": "stochastic", -} -_DRAWINGS = { - "": "none", - "none": "none", - "long": "long_position", - "longposition": "long_position", - "short": "short_position", - "shortposition": "short_position", - "forecast": "forecast", - "positionforecast": "forecast", - "barspattern": "bars_pattern", - "ghost": "ghost_feed", - "ghostfeed": "ghost_feed", - "xabcd": "xabcd", - "xabcdpattern": "xabcd", -} - - -def _number(ticket: Mapping[str, Any], key: str) -> float: - try: - value = float(ticket[key]) - except (KeyError, TypeError, ValueError) as exc: - raise ValueError(f"{key.replace('_', ' ')} must be a number") from exc - if not math.isfinite(value): - raise ValueError(f"{key.replace('_', ' ')} must be finite") - return value - - -def _ticket_drawing( - ticket: Mapping[str, Any], - *, - anchor: Any = (0.0, 1.0), - end: Any | None = None, -) -> Any: - side = str(ticket.get("side", "long")).lower().strip() - if side not in {"long", "short"}: - raise ValueError("side must be long or short") - entry = _number(ticket, "entry") - stop = _number(ticket, "stop") - target = _number(ticket, "target") - account_size = _number(ticket, "account_size") - risk_percent = _number(ticket, "risk_percent") - if account_size <= 0: - raise ValueError("account size must be positive") - if not 0 < risk_percent <= 100: - raise ValueError("risk percent must be greater than 0 and at most 100") - symbol = str(ticket.get("symbol", "SPY")) - meta = data.instrument(symbol) - qty_precision = 4 if meta.asset_class in {"FX", "Crypto"} else 2 - instrument = xy.instrument( - tick_size=meta.tick_size, - point_value=1.0, - lot_size=1.0, - qty_precision=qty_precision, - currency=meta.currency, - ) - kwargs = { - "source": "price", - "entry": (anchor, entry) if not isinstance(anchor, tuple) else (anchor[0], entry), - "stop": stop, - "target": target, - "account_size": account_size, - "risk": risk_percent / 100.0, - "risk_mode": "fraction", - "instrument": instrument, - "id": "paper-risk", - "style": {"profit_color": _GREEN, "loss_color": _RED, "text_color": _AMBER}, - } - if end is not None: - kwargs["end"] = end - return xy.long_position(**kwargs) if side == "long" else xy.short_position(**kwargs) - - -def ticket_metrics(ticket: Mapping[str, Any] | None) -> dict[str, Any]: - """Validate a paper ticket and return a small JSON-safe metric mapping.""" - - if not ticket: - return {"valid": False, "error": "Complete the paper ticket to preview risk."} - try: - drawing = _ticket_drawing(ticket) - metrics = drawing.metrics() - except (TypeError, ValueError) as exc: - return {"valid": False, "error": str(exc)} - return { - "valid": True, - "error": "", - "side": metrics["side"], - "entry": float(metrics["entry"]), - "stop": float(metrics["stop"]), - "target": float(metrics["target"]), - "account_size": float(metrics["account_size"]), - "risk_percent": float(ticket["risk_percent"]), - "risk_amount": float(metrics["risk_amount"]), - "quantity": float(metrics["qty_display"]), - "risk_reward": float(metrics["risk_reward"]), - "reward_risk": float(metrics["risk_reward"]), - "profit_pnl": float(metrics["profit_pnl"]), - "loss_pnl": float(metrics["loss_pnl"]), - } - - -def _default_ticket(symbol: str, side: str, last: float) -> dict[str, Any]: - if side == "long": - stop, target = last * 0.97, last * 1.06 - else: - stop, target = last * 1.03, last * 0.94 - return { - "symbol": symbol, - "side": side, - "entry": last, - "stop": stop, - "target": target, - "account_size": 100_000.0, - "risk_percent": 1.0, - } - - -def _future_date(days: int) -> str: - value = np.datetime64(data.AS_OF.isoformat(), "D") + np.timedelta64(days, "D") - return str(np.datetime_as_string(value, unit="D")) - - -def security_chart( - symbol: str, - range_key: str = "1Y", - resolution: str = "1D", - overlays: Iterable[str] = (), - oscillator: str = "None", - drawing: str = "None", - ticket: Mapping[str, Any] | None = None, -) -> Any: - """Build the state-dependent OHLCV finance chart for a security workspace.""" - - values = data.history(symbol, resolution=resolution, range_key=range_key) - meta = data.instrument(symbol) - overlay_values = (overlays,) if isinstance(overlays, str) else tuple(overlays) - normalized_overlays: list[str] = [] - for item in overlay_values: - try: - normalized = _OVERLAYS[_canonical(str(item))] - except KeyError as exc: - raise ValueError(f"unknown finance overlay {item!r}") from exc - if normalized not in normalized_overlays: - normalized_overlays.append(normalized) - try: - normalized_oscillator = _OSCILLATORS[_canonical(oscillator)] - except KeyError as exc: - raise ValueError(f"unknown oscillator {oscillator!r}") from exc - try: - normalized_drawing = _DRAWINGS[_canonical(drawing)] - except KeyError as exc: - raise ValueError(f"unknown drawing preset {drawing!r}") from exc - - layers: list[Any] = [ - xy.volume_bars( - source="price", - pane="volume", - id="volume", - style={"up_color": _GREEN, "down_color": _RED, "opacity": 0.62}, - ) - ] - for overlay in normalized_overlays: - if overlay == "sma20": - layers.append( - xy.moving_average( - source="price", - window=20, - method="sma", - id="SMA 20", - style={"color": _BLUE, "width": 1.35}, - ) - ) - elif overlay == "ema50": - layers.append( - xy.moving_average( - source="price", - window=50, - method="ema", - id="EMA 50", - style={"color": _VIOLET, "width": 1.35}, - ) - ) - elif overlay == "bollinger": - layers.append( - xy.bollinger_bands( - source="price", - window=20, - deviations=2.0, - id="Bollinger", - style={"color": "#70d6ff", "band_opacity": 0.44}, - ) - ) - elif overlay == "vwap": - layers.append( - xy.vwap(source="price", id="VWAP", style={"color": _GREEN, "width": 1.45}) - ) - elif overlay == "anchored_vwap": - layers.append( - xy.anchored_vwap( - source="price", - anchor={"bar": max(0, len(values) - 80)}, - bands=(1.0,), - id="Anchored VWAP", - style={"color": _AMBER, "band_opacity": 0.42}, - ) - ) - elif overlay == "volume_profile": - layers.append( - xy.anchored_volume_profile( - source="price", - anchor={"bar": max(0, len(values) - 120)}, - rows=30, - volume="up_down", - value_area=0.70, - id="Volume profile", - style={"up_color": _GREEN, "down_color": _RED}, - ) - ) - - if normalized_oscillator == "rsi": - layers.append( - xy.rsi(source="price", pane="oscillator", id="RSI 14", style={"color": _AMBER}) - ) - elif normalized_oscillator == "macd": - layers.append( - xy.macd( - source="price", - pane="oscillator", - id="MACD", - style={"macd_color": _BLUE, "signal_color": _AMBER}, - ) - ) - elif normalized_oscillator == "stochastic": - layers.append( - xy.stochastic( - source="price", - pane="oscillator", - id="Stochastic", - style={"k_color": _AMBER, "d_color": _VIOLET}, - ) - ) - - last = float(values.close[-1]) - if normalized_drawing in {"long_position", "short_position"}: - selected_side = "long" if normalized_drawing == "long_position" else "short" - selected_ticket = dict( - _default_ticket(meta.symbol, selected_side, last) if ticket is None else ticket - ) - selected_ticket["side"] = selected_side - selected_ticket.setdefault("symbol", meta.symbol) - if ticket_metrics(selected_ticket)["valid"]: - start_index = max(0, len(values) - 32) - layers.append( - _ticket_drawing( - selected_ticket, - anchor=str(values.dates[start_index]), - end=_future_date(21), - ) - ) - elif normalized_drawing == "forecast": - start_index = max(0, len(values) - 24) - layers.append( - xy.position_forecast( - source="price", - start=(str(values.dates[start_index]), float(values.close[start_index])), - target=(_future_date(35), last * 1.08), - id="forecast", - style={"color": _AMBER, "fill_color": "rgba(246,196,83,0.12)"}, - ) - ) - elif normalized_drawing == "bars_pattern": - layers.append( - xy.bars_pattern( - source="price", - start={"bar": max(0, len(values) - 64)}, - end={"bar": max(0, len(values) - 40)}, - destination=(_future_date(7), last), - normalize=True, - max_bars=30, - id="bars-pattern", - style={"up_color": _GREEN, "down_color": _RED, "opacity": 0.58}, - ) - ) - elif normalized_drawing == "ghost_feed": - layers.append( - xy.ghost_feed( - source="price", - anchor=(_future_date(7), last), - direction="up", - bars=24, - avg_hl_ticks=60.0, - variance_ticks=35.0, - tick_size=meta.tick_size, - seed=meta.seed + 900, - id="ghost-feed", - style={"up_color": _GREEN, "down_color": _RED, "opacity": 0.48}, - ) - ) - elif normalized_drawing == "xabcd": - indices = np.linspace(max(0, len(values) - 90), len(values) - 1, 5).round().astype(int) - points = [(str(values.dates[index]), float(values.close[index])) for index in indices] - layers.append( - xy.xabcd_pattern( - source="price", points=points, id="xabcd", style={"color": _VIOLET, "width": 1.5} - ) - ) - - active_tool = normalized_drawing if normalized_drawing != "none" else "crosshair" - return xy.finance_chart( - xy.candlestick( - values.dates, - values.open, - values.high, - values.low, - values.close, - volume=values.volume, - id="price", - name=f"{meta.symbol} OHLCV", - up_color=_GREEN, - down_color=_RED, - wick_color=_AMBER_DIM, - ), - *layers, - xy.x_axis(type_="time", tick_count=7), - xy.y_axis(label=f"{meta.currency} price", side="right", format=f".{meta.price_decimals}f"), - xy.legend(loc="upper left", ncols=3), - xy.finance_tools( - active=active_tool, - snap="ohlc", - selected="paper-risk" if active_tool.endswith("position") else None, - ), - title=f"{meta.symbol} · {resolution.upper()} · {range_key.upper()} · SIMULATED", - width="100%", - height=620, - style=_FINANCE_STYLE, - ) - - -def portfolio_performance_chart() -> Any: - series = data.portfolio_equity("1Y") - return xy.finance_chart( - xy.equity_drawdown( - x=series.dates, - equity=series.equity, - pane="drawdown", - mode="area", - id="portfolio-performance", - name="NAV", - style={"color": _AMBER, "fill_color": _AMBER, "drawdown_color": _RED}, - ), - xy.x_axis(type_="time", tick_count=6), - xy.y_axis(label="NAV (USD)", side="right"), - title="PORTFOLIO NAV + DRAWDOWN · SIMULATED", - width="100%", - height=410, - style=_FINANCE_STYLE, - ) - - -def portfolio_allocation_chart() -> xy.Chart: - labels, weights = data.portfolio_allocation() - colors = (_AMBER, _BLUE, _VIOLET, _GREEN, "#70d6ff", "#ff9f43", "#d5b3ff", _RED) - marks = [ - xy.bar([label], [float(weight)], name=label, color=colors[index % len(colors)], width=0.72) - for index, (label, weight) in enumerate(zip(labels, weights, strict=True)) - ] - return xy.bar_chart( - *marks, - xy.x_axis(label="security"), - xy.y_axis(label="weight (%)", side="right"), - xy.legend(show=False), - _theme(), - title="PORTFOLIO ALLOCATION", - width="100%", - height=285, - ) - - -def portfolio_contribution_chart() -> xy.Chart: - labels, contribution = data.portfolio_contribution() - marks = [ - xy.bar( - [label], [float(value)], name=label, color=_GREEN if value >= 0 else _RED, width=0.72 - ) - for label, value in zip(labels, contribution, strict=True) - ] - return xy.bar_chart( - *marks, - xy.hline(0.0, color=_AMBER_DIM, width=1.0), - xy.x_axis(label="security"), - xy.y_axis(label="unrealized P&L (USD)", side="right"), - xy.legend(show=False), - _theme(), - title="P&L CONTRIBUTION", - width="100%", - height=285, - ) - - -def portfolio_exposure_chart() -> xy.Chart: - labels, exposures = data.sector_exposures() - return xy.bar_chart( - xy.bar( - labels, exposures, orientation="horizontal", name="exposure", color=_AMBER, width=0.68 - ), - xy.x_axis(label="NAV exposure (%)"), - xy.y_axis(label="sector"), - xy.legend(show=False), - _theme(), - title="SECTOR EXPOSURE", - width="100%", - height=285, - ) - - -def _confidence(value: float | int | str) -> float: - if isinstance(value, str): - value = float(value.strip().rstrip("%")) - normalized = float(value) - if normalized > 1.0: - normalized /= 100.0 - if normalized not in {0.95, 0.99}: - raise ValueError("confidence must be 95% or 99%") - return normalized - - -def risk_distribution_chart(confidence: float | int | str = 0.95) -> Any: - normalized = _confidence(confidence) - returns = data.portfolio_returns("1Y") - return xy.finance_chart( - xy.returns_distribution( - returns, - bins=46, - confidence=normalized, - y="probability", - id="portfolio-returns", - style={"bar_color": _AMBER, "marker_color": _RED, "tail_color": "#71282b"}, - ), - xy.x_axis(label="daily return", format=".1%"), - xy.y_axis(label="probability", side="right", format=".1%"), - title=f"PORTFOLIO VaR / CVaR · {normalized:.0%} CONFIDENCE", - width="100%", - height=360, - style=_FINANCE_STYLE, - ) - - -def risk_correlation_chart() -> xy.Chart: - labels, matrix = data.correlation_matrix() - return xy.heatmap_chart( - xy.heatmap( - matrix, x=labels, y=labels, name="correlation", colormap="coolwarm", domain=(-1.0, 1.0) - ), - xy.x_axis(tick_label_angle=-34, tick_label_anchor="end"), - xy.y_axis(), - xy.colorbar(title="ρ"), - _theme(), - title="1Y RETURN CORRELATION", - width="100%", - height=360, - ) - - -def risk_factor_chart() -> xy.Chart: - labels, exposures = data.factor_exposures() - values = exposures * 100.0 - return xy.bar_chart( - xy.bar( - labels, values, orientation="horizontal", name="exposure", color=_VIOLET, width=0.68 - ), - xy.x_axis(label="exposure / beta × 100"), - xy.y_axis(label="factor"), - xy.legend(show=False), - _theme(), - title="FACTOR EXPOSURE", - width="100%", - height=320, - ) - - -def abbreviated_spec(chart: Any | None = None) -> dict[str, Any]: - """Return a deliberately small, JSON-safe chart/layer description.""" - - selected = chart or security_chart( - "AAPL", - range_key="6M", - overlays=("SMA 20", "VWAP"), - oscillator="RSI", - ) - if hasattr(selected, "build_payload"): - spec, _buffers = selected.build_payload() - else: - spec, _buffers = selected.figure().build_payload() - traces = [ - { - "kind": str(trace.get("kind", "")), - "name": str(trace.get("name") or ""), - } - for trace in spec.get("traces", []) - ] - layers = [] - for layer in spec.get("layers", []): - props = layer.get("props") or {} - materialized = ( - props.get("series") - or props.get("bars") - or props.get("profile") - or props.get("pattern") - or props.get("feed") - or {} - ) - layers.append( - { - "role": str(layer.get("role", "")), - "kind": str(layer.get("kind", "")), - "id": str(layer.get("id") or ""), - "pane": str(props.get("pane") or ""), - "rows": int(materialized.get("rows", 0)) - if isinstance(materialized, Mapping) - else 0, - } - ) - return { - "title": str(spec.get("title") or ""), - "trace_count": len(traces), - "traces": traces, - "layer_count": len(layers), - "layers": layers, - "x_axis": { - "label": str((spec.get("x_axis") or {}).get("label") or ""), - "type": str( - (spec.get("x_axis") or {}).get("kind") - or (spec.get("x_axis") or {}).get("type") - or "" - ), - }, - "y_axis": { - "label": str((spec.get("y_axis") or {}).get("label") or ""), - "type": str( - (spec.get("y_axis") or {}).get("kind") - or (spec.get("y_axis") or {}).get("type") - or "" - ), - }, - "tools": spec.get("tools") or {}, - } - - -MARKET_FOCUS_CHART = market_focus_chart() -MARKET_HEATMAP_CHART = market_heatmap_chart() -YIELD_CURVE_CHART = yield_curve_chart() - - -__all__ = [ - "MARKET_FOCUS_CHART", - "MARKET_HEATMAP_CHART", - "YIELD_CURVE_CHART", - "abbreviated_spec", - "market_focus_chart", - "market_heatmap_chart", - "market_pulse_chart", - "portfolio_allocation_chart", - "portfolio_contribution_chart", - "portfolio_exposure_chart", - "portfolio_performance_chart", - "risk_correlation_chart", - "risk_distribution_chart", - "risk_factor_chart", - "security_chart", - "ticket_metrics", - "yield_curve_chart", -] diff --git a/examples/reflex/xy_reflex_demo/components.py b/examples/reflex/xy_reflex_demo/components.py deleted file mode 100644 index 0f156b42..00000000 --- a/examples/reflex/xy_reflex_demo/components.py +++ /dev/null @@ -1,1611 +0,0 @@ -"""Terminal shell and workspace components for the Reflex example.""" - -from __future__ import annotations - -import inspect -import json -from collections.abc import Mapping, Sequence -from typing import Any - -import reflex as rx - -import reflex_xy -from reflex_xy.tokens import BUILDER_ATTR - -from . import charts, data -from .state import ( - CONFIDENCE_LEVELS, - DRAWINGS, - OSCILLATORS, - OVERLAYS, - RANGES, - RESOLUTIONS, - TerminalState, -) - -INK = "#050505" -PANEL = "#0b0c0c" -PANEL_ALT = "#111313" -BORDER = "#34301f" -AMBER = "#ffb000" -AMBER_SOFT = "#d08d00" -GREEN = "#27d17f" -RED = "#ff5a5f" -CYAN = "#57c7ff" -TEXT = "#ece7d7" -MUTED = "#8e8a7b" -MONO = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" - -AS_OF = str(getattr(data, "AS_OF_DATE", getattr(data, "AS_OF", "2026-07-31"))) - -# The yield curve intentionally uses the kernel-served fixed-data tier while -# the market heatmap below is passed as a direct xy.Chart static payload. -YIELD_CURVE_TOKEN = reflex_xy.inline(charts.YIELD_CURVE_CHART) - - -def _get(obj: Any, *names: str, default: Any = "—") -> Any: - for name in names: - if isinstance(obj, Mapping) and name in obj: - return obj[name] - if hasattr(obj, name): - return getattr(obj, name) - return default - - -def _items(value: Any) -> list[tuple[str, Any]]: - if isinstance(value, Mapping): - return [(str(key), item) for key, item in value.items()] - return [] - - -def _float(value: Any, default: float = 0.0) -> float: - try: - return float(value) - except (TypeError, ValueError): - return default - - -def _money(value: Any, *, decimals: int = 2) -> str: - number = _float(value) - sign = "-" if number < 0 else "" - return f"{sign}${abs(number):,.{decimals}f}" - - -def _percent(value: Any) -> str: - number = _float(value) - return f"{number:+.2f}%" - - -def _signed_color(value: Any) -> str: - return GREEN if _float(value) >= 0 else RED - - -def _compact_timestamp(value: Any, *, include_date: bool = False) -> str: - """Format the deterministic ISO timestamps for dense terminal rows.""" - text = str(value) - if len(text) >= 16 and text[10:11] == "T": - return f"{text[5:10]} {text[11:16]}" if include_date else text[11:16] - return text - - -def terminal_button( - label: Any, - *, - on_click: Any = None, - active: Any = False, - compact: bool = False, - **props: Any, -) -> rx.Component: - return rx.button( - label, - on_click=on_click, - variant="surface", - radius="none", - box_shadow="none", - margin="0", - flex_shrink="0", - white_space="nowrap", - min_height="25px" if compact else "30px", - padding="2px 7px" if compact else "4px 9px", - border=f"1px solid {AMBER}" if active is True else f"1px solid {BORDER}", - background=AMBER if active is True else PANEL_ALT, - color=INK if active is True else AMBER, - font_family=MONO, - font_size="10px" if compact else "11px", - font_weight="700", - letter_spacing="0.04em", - cursor="pointer", - _hover={"background": AMBER, "color": INK, "border_color": AMBER}, - _focus_visible={"outline": f"2px solid {CYAN}", "outline_offset": "2px"}, - **props, - ) - - -def state_button(label: str, value: Any, event: Any, *, compact: bool = True) -> rx.Component: - """A terminal button whose selected state is a Reflex boolean var.""" - return rx.button( - label, - on_click=event, - variant="surface", - radius="none", - box_shadow="none", - margin="0", - flex_shrink="0", - white_space="nowrap", - min_height="25px" if compact else "30px", - padding="2px 7px" if compact else "4px 9px", - border=f"1px solid {BORDER}", - background=rx.cond(value, AMBER, PANEL_ALT), - color=rx.cond(value, INK, AMBER), - font_family=MONO, - font_size="10px" if compact else "11px", - font_weight="700", - cursor="pointer", - _hover={"border_color": AMBER}, - _focus_visible={"outline": f"2px solid {CYAN}", "outline_offset": "2px"}, - ) - - -def panel( - title: Any, - *children: rx.Component, - subtitle: Any | None = None, - action: rx.Component | None = None, - **props: Any, -) -> rx.Component: - header_children: list[rx.Component] = [ - rx.text( - title, - color=AMBER, - font_family=MONO, - font_size="11px", - font_weight="800", - letter_spacing="0.08em", - text_transform="uppercase", - overflow="hidden", - text_overflow="ellipsis", - white_space="nowrap", - ) - ] - if subtitle is not None: - header_children.append( - rx.text( - subtitle, - color=MUTED, - font_family=MONO, - font_size="9px", - margin_left="8px", - min_width="0", - overflow="hidden", - text_overflow="ellipsis", - white_space="nowrap", - ) - ) - props.setdefault("width", "100%") - return rx.box( - rx.hstack( - rx.hstack(*header_children, spacing="1", align="center", min_width="0"), - rx.box(action or rx.box(), flex_shrink="0"), - justify="between", - align="center", - min_height="28px", - padding="4px 7px", - border_bottom=f"1px solid {BORDER}", - background="#16140c", - ), - rx.box(*children, padding="7px", width="100%"), - background=PANEL, - border=f"1px solid {BORDER}", - min_width="0", - overflow="hidden", - **props, - ) - - -def metric(label: str, value: Any, *, color: str = TEXT, note: Any = None) -> rx.Component: - return rx.box( - rx.text(label, color=MUTED, font_family=MONO, font_size="9px", letter_spacing="0.06em"), - rx.text( - value, - color=color, - font_family=MONO, - font_size="16px", - font_weight="750", - overflow="hidden", - text_overflow="ellipsis", - white_space="nowrap", - ), - rx.text(note, color=MUTED, font_family=MONO, font_size="9px") - if note is not None - else rx.box(), - min_width="0", - ) - - -def terminal_select(options: Sequence[str], value: Any, on_change: Any, label: str) -> rx.Component: - return rx.vstack( - rx.text(label, color=MUTED, font_size="9px", font_family=MONO), - rx.select( - list(options), - value=value, - on_change=on_change, - size="1", - radius="none", - width="100%", - color_scheme="amber", - ), - spacing="1", - align="start", - min_width="110px", - ) - - -def terminal_input(label: str, value: Any, on_change: Any, **props: Any) -> rx.Component: - return rx.vstack( - rx.text(label, color=MUTED, font_family=MONO, font_size="9px"), - rx.input( - value=value, - on_change=on_change, - size="1", - radius="none", - background=INK, - border=f"1px solid {BORDER}", - color=TEXT, - font_family=MONO, - font_size="11px", - _focus={"border_color": CYAN, "box_shadow": f"0 0 0 1px {CYAN}"}, - **props, - ), - spacing="1", - align="start", - min_width="0", - ) - - -def command_bar() -> rx.Component: - return rx.vstack( - rx.hstack( - rx.text("XY", color=INK, background=AMBER, padding="3px 7px", font_weight="900"), - rx.text( - "COMMAND", - color=AMBER, - font_weight="800", - font_size="10px", - display=rx.breakpoints(initial="none", md="block"), - ), - rx.input( - value=TerminalState.command, - on_change=TerminalState.set_command, - on_key_down=TerminalState.command_key, - placeholder="MKTS | DES AAPL | PORT | RISK | NEWS | HELP", - aria_label="Terminal command", - radius="none", - size="2", - background=INK, - border=f"1px solid {AMBER_SOFT}", - color=TEXT, - font_family=MONO, - font_size="12px", - flex="1", - min_width="0", - _placeholder={"color": MUTED}, - _focus={"border_color": CYAN, "box_shadow": f"0 0 0 1px {CYAN}"}, - ), - terminal_button("GO", on_click=TerminalState.execute_command, compact=False), - rx.text( - "SIM DATA", - color=INK, - background=RED, - font_family=MONO, - font_size="9px", - font_weight="900", - padding="4px 7px", - white_space="nowrap", - display=rx.breakpoints(initial="block", sm="none"), - flex_shrink="0", - ), - rx.text( - "SIMULATED DATA", - color=INK, - background=RED, - font_family=MONO, - font_size="9px", - font_weight="900", - padding="4px 7px", - white_space="nowrap", - display=rx.breakpoints(initial="none", sm="block"), - flex_shrink="0", - ), - width="100%", - align="center", - gap=rx.breakpoints(initial="4px", sm="8px"), - ), - rx.cond( - TerminalState.help_visible, - rx.text( - "MKTS Global markets DES Security PORT Portfolio " - "RISK Risk monitor NEWS Newswire", - color=CYAN, - font_family=MONO, - font_size="10px", - padding="4px 8px", - border=f"1px solid {CYAN}", - width="100%", - ), - rx.box(), - ), - spacing="1", - width="100%", - ) - - -def ticker_tape() -> rx.Component: - return rx.hstack( - rx.foreach(TerminalState.tape_quotes, _live_quote_cell), - width="100%", - overflow_x="auto", - spacing="0", - background="#080909", - border_top=f"1px solid {BORDER}", - border_bottom=f"1px solid {BORDER}", - font_family=MONO, - font_size="10px", - scrollbar_width="thin", - ) - - -def _live_quote_cell(row: rx.Var[dict[str, str]]) -> rx.Component: - return rx.hstack( - rx.text(row["symbol"], color=AMBER, font_weight="800"), - rx.text(row["last"], color=TEXT), - rx.text( - row["change"], - color=rx.cond(row["direction"] == "UP", GREEN, RED), - ), - spacing="2", - align="center", - padding="2px 9px", - border_right=f"1px solid {BORDER}", - white_space="nowrap", - ) - - -def watchlist() -> rx.Component: - rows = list(data.watchlist_rows()) - entries = [] - for row in rows: - symbol = str(_get(row, "symbol", "ticker")) - last = _get(row, "last", "price", "close", default=0.0) - change = _get( - row, - "change_percent", - "change_pct", - "percent_change", - "pct_change", - default=0.0, - ) - entries.append( - rx.button( - rx.grid( - rx.text(symbol, color=AMBER, font_weight="800"), - rx.text(f"{_float(last):,.2f}", color=TEXT, text_align="right"), - rx.text(_percent(change), color=_signed_color(change), text_align="right"), - grid_template_columns="minmax(64px, 1fr) minmax(70px, 1fr) 68px", - width="100%", - align_items="center", - ), - on_click=TerminalState.select_symbol(symbol), - variant="surface", - radius="none", - box_shadow="none", - background="transparent", - width="100%", - min_height="27px", - padding="3px 5px", - border_bottom="1px solid #1d1d19", - font_family=MONO, - font_size="10px", - _hover={"background": "#231c08"}, - _focus_visible={"outline": f"2px solid {CYAN}", "outline_offset": "-2px"}, - ) - ) - return panel( - "Watchlist", - rx.vstack(*entries, spacing="0", width="100%"), - subtitle=f"{len(entries)} securities", - padding="0", - height="100%", - ) - - -def _breadth_cards() -> rx.Component: - breadth = data.breadth_metrics() - cards = [] - for label, value in _items(breadth): - display = _percent(value) if "percent" in label else value - cards.append(metric(label.replace("_", " "), display)) - return rx.grid(*cards, columns=rx.breakpoints(initial="2", md="4"), gap="10px", width="100%") - - -def _movers_table() -> rx.Component: - rows = list(data.movers()) - body = [] - for row in rows: - symbol = str(_get(row, "symbol", "ticker")) - change = _get( - row, - "change_percent", - "change_pct", - "percent_change", - "pct_change", - default=0.0, - ) - body.append( - rx.grid( - rx.text(symbol, color=AMBER, font_weight="800"), - rx.text( - str(_get(row, "name", "label", default=symbol)), - color=TEXT, - overflow="hidden", - text_overflow="ellipsis", - white_space="nowrap", - ), - rx.text(_percent(change), color=_signed_color(change), text_align="right"), - grid_template_columns="64px minmax(0, 1fr) 62px", - width="100%", - padding="4px 2px", - border_bottom="1px solid #1d1d19", - font_family=MONO, - font_size="10px", - ) - ) - return rx.vstack(*body, spacing="0", width="100%") - - -def markets_workspace() -> rx.Component: - return rx.vstack( - panel( - "Market focus · SPY", - rx.box( - reflex_xy.chart( - charts.MARKET_FOCUS_CHART, - height=rx.breakpoints(initial="390px", sm="520px"), - min_width=rx.breakpoints(initial="520px", sm="100%"), - id="market-finance-chart", - ), - width="100%", - overflow_x="auto", - scrollbar_width="thin", - ), - subtitle="native FinanceChart · OHLCV · studies · tools", - action=terminal_button( - "DES SPY", - on_click=TerminalState.select_symbol("SPY"), - compact=True, - ), - ), - rx.grid( - panel( - "Market breadth", - _breadth_cards(), - grid_column=rx.breakpoints(initial="1", md="span 2"), - ), - panel("Top movers", _movers_table()), - columns=rx.breakpoints(initial="1", md="3"), - gap="8px", - width="100%", - ), - rx.grid( - panel( - "Cross-asset heatmap", - reflex_xy.chart(charts.MARKET_HEATMAP_CHART, height="300px", id="market-heatmap"), - subtitle="direct xy.Chart payload", - ), - panel( - "Yield curve", - reflex_xy.chart(YIELD_CURVE_TOKEN, height="300px", id="yield-curve"), - subtitle="inline() kernel token", - ), - columns=rx.breakpoints(initial="1", md="2"), - gap="8px", - width="100%", - ), - panel( - "Live market pulse", - reflex_xy.chart(TerminalState.market_pulse, height="230px", id="market-pulse"), - subtitle="append-driven stream", - action=terminal_button( - rx.cond(TerminalState.streaming, "STOP", "GO LIVE"), - on_click=TerminalState.stream_quotes, - ), - ), - spacing="2", - width="100%", - ) - - -def _selected_instrument_card() -> rx.Component: - fallback: rx.Component = rx.box() - for symbol in reversed(data.instrument_symbols()): - instrument = data.instrument(symbol) - quote = data.quote(symbol) - content = rx.vstack( - rx.text( - str(_get(instrument, "name", "description", default=symbol)), color=TEXT, size="2" - ), - rx.grid( - metric("LAST", _money(quote.last), color=TEXT), - metric( - "DAY CHANGE", - _percent(quote.change_percent), - color=_signed_color(quote.change_percent), - ), - metric("VOLUME", f"{quote.volume:,.0f}"), - metric( - "ASSET", - str(_get(instrument, "asset_class", "kind", default="Security")), - ), - metric("SECTOR", str(_get(instrument, "sector", default="Global"))), - metric("BETA", f"{_float(_get(instrument, 'beta', default=0)):.2f}"), - metric("VENUE", str(_get(instrument, "exchange", "venue", default="Global"))), - metric("CCY", str(_get(instrument, "currency", default="USD"))), - columns=rx.breakpoints(initial="1", sm="2", md="4"), - gap="8px", - width="100%", - ), - spacing="2", - width="100%", - ) - fallback = rx.cond(TerminalState.selected_symbol == str(symbol), content, fallback) - return fallback - - -def security_controls() -> rx.Component: - return panel( - "Security controls", - rx.vstack( - rx.flex( - terminal_select( - list(data.instrument_symbols()), - TerminalState.selected_symbol, - TerminalState.select_symbol, - "SYMBOL", - ), - rx.vstack( - rx.text("RANGE", color=MUTED, font_size="9px", font_family=MONO), - rx.hstack( - *[ - state_button( - value, - TerminalState.range_key == value, - TerminalState.set_range_key(value), - ) - for value in RANGES - ], - spacing="1", - wrap="wrap", - ), - spacing="1", - align="start", - ), - rx.vstack( - rx.text("RESOLUTION", color=MUTED, font_size="9px", font_family=MONO), - rx.hstack( - *[ - state_button( - value, - TerminalState.resolution == value, - TerminalState.set_resolution(value), - ) - for value in RESOLUTIONS - ], - spacing="1", - ), - spacing="1", - align="start", - ), - gap="10px", - width="100%", - wrap="wrap", - align="end", - ), - rx.vstack( - rx.text("OVERLAYS", color=MUTED, font_size="9px", font_family=MONO), - rx.hstack( - *[ - state_button( - overlay, - TerminalState.overlays.contains(overlay), - TerminalState.toggle_overlay(overlay), - ) - for overlay in OVERLAYS - ], - spacing="1", - wrap="wrap", - ), - spacing="1", - align="start", - ), - rx.flex( - terminal_select( - OSCILLATORS, - TerminalState.oscillator, - TerminalState.set_oscillator, - "OSCILLATOR", - ), - terminal_select( - DRAWINGS, TerminalState.drawing, TerminalState.set_drawing, "DRAWING PRESET" - ), - gap="10px", - width="100%", - wrap="wrap", - ), - spacing="2", - width="100%", - ), - ) - - -def paper_ticket() -> rx.Component: - return panel( - "Paper risk ticket", - rx.vstack( - rx.grid( - terminal_select( - ("Long", "Short"), - TerminalState.ticket_side, - TerminalState.set_ticket_side, - "SIDE", - ), - terminal_input("ENTRY", TerminalState.ticket_entry, TerminalState.set_ticket_entry), - terminal_input("STOP", TerminalState.ticket_stop, TerminalState.set_ticket_stop), - terminal_input( - "TARGET", TerminalState.ticket_target, TerminalState.set_ticket_target - ), - columns=rx.breakpoints(initial="1", sm="2", md="4"), - gap="7px", - width="100%", - ), - rx.grid( - terminal_input( - "ACCOUNT SIZE", TerminalState.ticket_account, TerminalState.set_ticket_account - ), - terminal_input("RISK %", TerminalState.ticket_risk, TerminalState.set_ticket_risk), - metric("RISK AMOUNT", TerminalState.ticket_risk_amount), - metric("POSITION SIZE", TerminalState.ticket_position_size), - metric("REWARD / RISK", TerminalState.ticket_reward_risk), - columns=rx.breakpoints(initial="1", sm="2", md="5"), - gap="8px", - width="100%", - ), - rx.cond( - TerminalState.ticket_valid, - rx.text( - "VALID PAPER SCENARIO — NO ORDER WILL BE SUBMITTED", - color=GREEN, - font_family=MONO, - font_size="9px", - ), - rx.text(TerminalState.ticket_error, color=RED, font_family=MONO, font_size="9px"), - ), - spacing="2", - width="100%", - ), - subtitle="simulation only", - ) - - -def _story_teasers(stories: Sequence[Any]) -> rx.Component: - return rx.vstack( - *[ - rx.box( - rx.text( - str(_get(story, "timestamp", "time", default="--:--")), - color=CYAN, - font_size="9px", - ), - rx.text(str(_get(story, "headline", "title")), color=TEXT, font_size="10px"), - padding="5px 0", - border_bottom="1px solid #1d1d19", - width="100%", - ) - for story in stories[:4] - ], - spacing="0", - width="100%", - font_family=MONO, - ) - - -def _related_news() -> rx.Component: - result: rx.Component = rx.text("NO RELATED STORIES", color=MUTED, font_size="9px") - for symbol in reversed(data.instrument_symbols()): - stories = data.stories(symbol) - content = _story_teasers(stories) if stories else result - result = rx.cond(TerminalState.selected_symbol == symbol, content, result) - return result - - -def security_workspace() -> rx.Component: - return rx.vstack( - security_controls(), - panel( - rx.hstack( - rx.text(TerminalState.selected_symbol, color=AMBER, font_weight="900"), - rx.text("OHLCV ANALYSIS", color=TEXT), - spacing="2", - ), - rx.box( - reflex_xy.chart( - TerminalState.security_figure, - on_hover=TerminalState.on_chart_hover, - on_view_change=TerminalState.on_chart_view, - height=rx.breakpoints(initial="430px", sm="610px"), - min_width=rx.breakpoints(initial="480px", sm="100%"), - id="security-chart", - ), - width="100%", - overflow_x="auto", - scrollbar_width="thin", - ), - subtitle=TerminalState.view_status, - ), - rx.grid( - panel("Instrument", _selected_instrument_card()), - panel("Related news", _related_news()), - columns=rx.breakpoints(initial="1", md="2"), - gap="8px", - width="100%", - ), - paper_ticket(), - spacing="2", - width="100%", - ) - - -def _summary_metrics() -> rx.Component: - summary = data.portfolio_summary() - items = _items(summary) - return rx.grid( - *[ - metric( - label.replace("_", " "), - ( - f"{_float(value):.2f}%" - if "percent" in label.lower() - else _money(value) - if any(key in label.lower() for key in ("nav", "pnl", "value", "cash", "cost")) - else value - ), - color=_signed_color(value) if "pnl" in label.lower() else TEXT, - ) - for label, value in items - ], - columns=rx.breakpoints(initial="2", md="4"), - gap="12px", - width="100%", - ) - - -def positions_table() -> rx.Component: - column_template = "70px 80px 120px 110px 85px" - rows = [] - for position in data.position_rows(): - symbol = str(_get(position, "symbol", "ticker")) - pnl = _get(position, "pnl", "unrealized_pnl", "profit_loss", default=0.0) - rows.append( - rx.button( - rx.grid( - rx.text(symbol, color=AMBER, font_weight="900"), - rx.text( - f"{_float(_get(position, 'quantity', 'units', default=0)):,.2f}", - text_align="right", - ), - rx.text( - _money(_get(position, "market_value", "value", default=0)), - text_align="right", - ), - rx.text(_money(pnl), color=_signed_color(pnl), text_align="right"), - rx.text( - _percent( - _get( - position, - "pnl_percent", - "pnl_pct", - "return_pct", - default=0, - ) - ), - color=_signed_color(pnl), - text_align="right", - ), - grid_template_columns=column_template, - width="100%", - ), - on_click=TerminalState.drilldown_position(symbol), - variant="surface", - radius="none", - box_shadow="none", - background="transparent", - min_height="30px", - padding="4px 3px", - width="100%", - color=TEXT, - font_family=MONO, - font_size="10px", - border_bottom="1px solid #1d1d19", - _hover={"background": "#231c08"}, - ) - ) - return rx.box( - rx.vstack( - rx.grid( - *[ - rx.text(label, color=MUTED, text_align="right" if index else "left") - for index, label in enumerate(("SYMBOL", "QTY", "MKT VALUE", "P&L", "RETURN")) - ], - grid_template_columns=column_template, - width="100%", - padding="3px", - font_family=MONO, - font_size="9px", - ), - *rows, - spacing="0", - min_width="465px", - width="100%", - ), - width="100%", - overflow_x="auto", - ) - - -def portfolio_workspace() -> rx.Component: - return rx.vstack( - panel("Portfolio summary", _summary_metrics(), subtitle="fictional multi-asset book"), - panel( - "NAV & drawdown", - reflex_xy.chart( - TerminalState.portfolio_figure, height="330px", id="portfolio-performance" - ), - subtitle="@reflex_xy.figure", - ), - panel("Positions", positions_table(), subtitle="select a row for DES"), - rx.grid( - panel( - "Allocation", - reflex_xy.chart( - charts.portfolio_allocation_chart(), height="245px", id="portfolio-allocation" - ), - ), - panel( - "Contribution", - reflex_xy.chart( - charts.portfolio_contribution_chart(), - height="245px", - id="portfolio-contribution", - ), - ), - panel( - "Exposure", - reflex_xy.chart( - charts.portfolio_exposure_chart(), height="245px", id="portfolio-exposure" - ), - ), - columns=rx.breakpoints(initial="1", md="2", lg="3"), - gap="8px", - width="100%", - ), - spacing="2", - width="100%", - ) - - -def scenario_table(confidence: float = 0.95) -> rx.Component: - column_template = "140px minmax(230px, 1fr) 110px" - scenarios = list(data.stress_scenarios(confidence)) - rows = [] - for scenario in scenarios: - name = str(_get(scenario, "name", "scenario", "label")) - impact = _get(scenario, "impact", "pnl", "portfolio_impact", default=0.0) - rows.append( - rx.button( - rx.grid( - rx.text(name, color=AMBER, font_weight="800"), - rx.text( - str(_get(scenario, "shock", "description", default="Deterministic shock")), - color=MUTED, - ), - rx.text( - _money(impact), - color=_signed_color(impact), - text_align="right", - white_space="nowrap", - ), - grid_template_columns=column_template, - width="100%", - ), - on_click=TerminalState.set_scenario(name), - variant="surface", - radius="none", - box_shadow="none", - width="100%", - min_height="30px", - color=TEXT, - font_family=MONO, - font_size="10px", - border_bottom="1px solid #1d1d19", - background=rx.cond( - TerminalState.selected_scenario == name, "#231c08", "transparent" - ), - _hover={"background": "#231c08"}, - ) - ) - return rx.box( - rx.vstack(*rows, spacing="0", width="100%", min_width="520px"), - width="100%", - overflow_x="auto", - ) - - -def risk_workspace() -> rx.Component: - return rx.vstack( - panel( - "Risk controls", - rx.hstack( - rx.text("CONFIDENCE", color=MUTED, font_family=MONO, font_size="9px"), - *[ - state_button( - confidence, - TerminalState.confidence_label == confidence, - TerminalState.set_confidence(confidence), - ) - for confidence in CONFIDENCE_LEVELS - ], - spacing="1", - align="center", - wrap="wrap", - ), - ), - rx.grid( - panel( - "Historical VaR / CVaR", - reflex_xy.chart(TerminalState.risk_figure, height="310px", id="risk-distribution"), - subtitle=TerminalState.confidence_label, - ), - panel( - "Correlation matrix", - reflex_xy.chart( - charts.risk_correlation_chart(), height="310px", id="risk-correlation" - ), - ), - columns=rx.breakpoints(initial="1", md="2"), - gap="8px", - width="100%", - ), - rx.grid( - panel( - "Factor exposure", - reflex_xy.chart(charts.risk_factor_chart(), height="265px", id="risk-factors"), - ), - panel( - "Stress scenarios", - rx.cond( - TerminalState.confidence_label == "99%", - scenario_table(0.99), - scenario_table(0.95), - ), - subtitle="select scenario", - ), - columns=rx.breakpoints(initial="1", md="2"), - gap="8px", - width="100%", - ), - spacing="2", - width="100%", - ) - - -def _story_id(story: Any) -> str: - return str(_get(story, "id", "story_id", default="N001")) - - -def _story_detail(story: Any) -> rx.Component: - sentiment = _get(story, "sentiment", default="Neutral") - impact = _get(story, "impact", "importance", default="Medium") - return rx.vstack( - rx.hstack( - rx.text(str(_get(story, "source", default="XY NEWS")), color=CYAN), - rx.text( - _compact_timestamp( - _get(story, "timestamp", "time", default="--:--"), include_date=True - ), - color=MUTED, - ), - spacing="2", - ), - rx.heading(str(_get(story, "headline", "title")), color=TEXT, size="4", font_family=MONO), - rx.hstack( - rx.text( - f"SENTIMENT {sentiment}", - color=GREEN - if str(sentiment).lower() == "positive" - else RED - if str(sentiment).lower() == "negative" - else AMBER, - ), - rx.text(f"IMPACT {impact}", color=AMBER), - spacing="3", - font_size="10px", - ), - rx.text( - str(_get(story, "body", "summary", "description", default="Simulated market story.")), - color=TEXT, - font_family=MONO, - font_size="12px", - line_height="1.6", - ), - spacing="3", - align="start", - width="100%", - ) - - -def news_list() -> rx.Component: - rows = [] - for story in data.stories(): - story_id = _story_id(story) - sentiment = str(_get(story, "sentiment", default="Neutral")) - rows.append( - rx.button( - rx.grid( - rx.text( - _compact_timestamp(_get(story, "timestamp", "time", default="--:--")), - color=CYAN, - ), - rx.text( - str(_get(story, "headline", "title")), - color=TEXT, - text_align="left", - ), - rx.text( - sentiment[:3].upper(), - color=GREEN - if sentiment.lower() == "positive" - else RED - if sentiment.lower() == "negative" - else AMBER, - text_align="right", - ), - grid_template_columns="58px minmax(0, 1fr) 40px", - width="100%", - align_items="start", - ), - on_click=TerminalState.select_story(story_id), - variant="surface", - radius="none", - box_shadow="none", - width="100%", - height="auto", - min_height="42px", - padding="5px 3px", - border_bottom="1px solid #1d1d19", - background=rx.cond( - TerminalState.selected_story == story_id, "#231c08", "transparent" - ), - font_family=MONO, - font_size="10px", - white_space="normal", - _hover={"background": "#231c08"}, - ) - ) - return rx.vstack(*rows, spacing="0", width="100%") - - -def selected_story_detail() -> rx.Component: - stories = list(data.stories()) - if not stories: - return rx.text("NO STORIES", color=MUTED) - result = _story_detail(stories[0]) - for story in reversed(stories): - result = rx.cond( - TerminalState.selected_story == _story_id(story), _story_detail(story), result - ) - return result - - -def calendar_table() -> rx.Component: - column_template = "82px 42px minmax(200px, 1fr) 72px 76px 76px" - rows = [] - for event in data.calendar_events(): - importance = str(_get(event, "importance", "impact", default="Medium")) - rows.append( - rx.grid( - rx.text( - _compact_timestamp( - _get(event, "time", "timestamp", default="--:--"), include_date=True - ), - color=CYAN, - ), - rx.text(str(_get(event, "country", "region", default="US")), color=AMBER), - rx.text(str(_get(event, "event", "name", "title")), color=TEXT), - rx.text(importance, color=RED if importance.lower() == "high" else AMBER), - rx.text(str(_get(event, "consensus", "forecast", default="—")), text_align="right"), - rx.text(str(_get(event, "prior", "previous", default="—")), text_align="right"), - grid_template_columns=column_template, - width="100%", - padding="5px 2px", - border_bottom="1px solid #1d1d19", - font_family=MONO, - font_size="10px", - ) - ) - header = rx.grid( - *[ - rx.text(label, color=MUTED, text_align="right" if index >= 4 else "left") - for index, label in enumerate( - ("TIME", "REGION", "EVENT", "IMPACT", "CONSENSUS", "PRIOR") - ) - ], - grid_template_columns=column_template, - width="100%", - padding="3px 2px", - font_family=MONO, - font_size="9px", - border_bottom=f"1px solid {BORDER}", - ) - return rx.box( - rx.vstack(header, *rows, spacing="0", width="100%", min_width="650px"), - width="100%", - overflow_x="auto", - ) - - -def news_workspace() -> rx.Component: - return rx.vstack( - rx.grid( - panel("Newswire", news_list(), subtitle="fictional headlines"), - panel("Story detail", selected_story_detail()), - columns=rx.breakpoints(initial="1", md="2"), - gap="8px", - width="100%", - ), - panel("Economic calendar", calendar_table(), subtitle=f"as of {AS_OF}"), - spacing="2", - width="100%", - ) - - -def workspace() -> rx.Component: - return rx.box( - rx.cond( - TerminalState.workspace == "MARKETS", - markets_workspace(), - rx.cond( - TerminalState.workspace == "SECURITY", - security_workspace(), - rx.cond( - TerminalState.workspace == "PORTFOLIO", - portfolio_workspace(), - rx.cond(TerminalState.workspace == "RISK", risk_workspace(), news_workspace()), - ), - ), - ), - width="100%", - min_width="0", - ) - - -def context_rail() -> rx.Component: - return rx.vstack( - panel( - "Context", - rx.vstack( - rx.text(TerminalState.workspace, color=AMBER, font_family=MONO, font_weight="900"), - rx.text( - f"DES {TerminalState.selected_symbol}", - color=TEXT, - font_family=MONO, - font_size="11px", - ), - rx.text(TerminalState.view_status, color=MUTED, font_family=MONO, font_size="9px"), - spacing="1", - align="start", - ), - ), - panel( - "Chart readout", - rx.cond( - TerminalState.hovered.length() > 0, - rx.vstack( - rx.text( - f"X {TerminalState.hovered['x']}", - color=TEXT, - font_family=MONO, - font_size="10px", - ), - rx.text( - f"Y {TerminalState.hovered['y']}", - color=TEXT, - font_family=MONO, - font_size="10px", - ), - spacing="1", - align="start", - ), - rx.text("HOVER A CHART POINT", color=MUTED, font_family=MONO, font_size="9px"), - ), - ), - panel( - "Quick functions", - rx.vstack( - terminal_button( - "DES AAPL", on_click=TerminalState.select_symbol("AAPL"), width="100%" - ), - terminal_button( - "PORTFOLIO", on_click=TerminalState.choose_workspace("PORTFOLIO"), width="100%" - ), - terminal_button( - "RISK MONITOR", on_click=TerminalState.choose_workspace("RISK"), width="100%" - ), - terminal_button( - "NEWSWIRE", on_click=TerminalState.choose_workspace("NEWS"), width="100%" - ), - spacing="1", - width="100%", - ), - ), - panel( - "System", - rx.vstack( - rx.hstack( - rx.text("DATA"), - rx.text("SIMULATED", color=RED), - justify="between", - width="100%", - ), - rx.hstack( - rx.text("AS OF"), rx.text(AS_OF, color=TEXT), justify="between", width="100%" - ), - rx.hstack( - rx.text("STREAM"), - rx.text( - rx.cond(TerminalState.streaming, "LIVE", "IDLE"), - color=rx.cond(TerminalState.streaming, GREEN, MUTED), - ), - justify="between", - width="100%", - ), - terminal_button( - rx.cond(TerminalState.streaming, "STOP LIVE TAPE", "START LIVE TAPE"), - on_click=TerminalState.stream_quotes, - width="100%", - ), - spacing="1", - width="100%", - color=MUTED, - font_family=MONO, - font_size="9px", - ), - ), - width="100%", - spacing="2", - ) - - -def function_keys() -> rx.Component: - keys = ( - ("F1", "MARKETS"), - ("F2", "SECURITY"), - ("F3", "PORTFOLIO"), - ("F4", "RISK"), - ("F5", "NEWS"), - ) - return rx.hstack( - *[ - rx.button( - rx.hstack( - rx.text(key, color=INK, background=AMBER, padding="2px 4px", font_weight="900"), - rx.text(label, color=TEXT), - spacing="1", - ), - on_click=TerminalState.choose_workspace(label), - variant="surface", - radius="none", - box_shadow="none", - background=rx.cond(TerminalState.workspace == label, "#231c08", "transparent"), - margin="0", - flex_shrink="0", - white_space="nowrap", - min_height="27px", - padding="2px 6px", - border_right=f"1px solid {BORDER}", - border_bottom=rx.cond( - TerminalState.workspace == label, - f"2px solid {AMBER}", - "2px solid transparent", - ), - font_family=MONO, - font_size="9px", - _hover={"background": "#231c08"}, - _focus_visible={"outline": f"2px solid {CYAN}", "outline_offset": "-2px"}, - ) - for key, label in keys - ], - terminal_button("DEV", on_click=TerminalState.toggle_developer, compact=True), - width="100%", - overflow_x="auto", - spacing="0", - background="#090a0a", - border_top=f"1px solid {BORDER}", - border_bottom=f"1px solid {BORDER}", - ) - - -def _source(obj: Any) -> str: - fget = getattr(obj, "_fget", None) - if fget is not None: - builder = getattr(fget, BUILDER_ATTR, None) - return inspect.getsource(builder if builder is not None else fget) - handler = getattr(obj, "fn", None) - return inspect.getsource(handler if handler is not None else obj) - - -def developer_drawer() -> rx.Component: - source = "\n\n".join( - _source(obj) - for obj in ( - TerminalState.security_figure, - TerminalState.risk_figure, - TerminalState.stream_quotes, - ) - ) - try: - spec = json.dumps( - charts.abbreviated_spec( - charts.security_chart( - "AAPL", - range_key="3M", - overlays=("SMA 20", "VWAP"), - oscillator="RSI", - ) - ), - indent=2, - default=str, - ) - except (TypeError, ValueError): - spec = "chart spec unavailable" - return rx.cond( - TerminalState.developer_open, - rx.fragment( - rx.box( - on_click=TerminalState.toggle_developer, - position="fixed", - inset="0", - background="rgba(0,0,0,0.68)", - z_index="60", - ), - rx.box( - rx.hstack( - rx.text("DEVELOPER CONSOLE", color=AMBER, font_family=MONO, font_weight="900"), - terminal_button("CLOSE", on_click=TerminalState.toggle_developer), - justify="between", - width="100%", - padding="8px", - border_bottom=f"1px solid {BORDER}", - ), - rx.hstack( - *[ - state_button( - tab, - TerminalState.developer_tab == tab, - TerminalState.set_developer_tab(tab), - ) - for tab in ("SOURCE", "STATE", "SPEC") - ], - padding="8px", - spacing="1", - ), - rx.box( - rx.cond( - TerminalState.developer_tab == "SOURCE", - rx.el.pre(source), - rx.cond( - TerminalState.developer_tab == "STATE", - rx.el.pre(TerminalState.state_snapshot), - rx.el.pre(spec), - ), - ), - padding="10px", - color="#d9f99d", - font_family=MONO, - font_size="10px", - line_height="1.45", - white_space="pre-wrap", - overflow="auto", - flex="1", - ), - position="fixed", - right="0", - top="0", - bottom="0", - width=rx.breakpoints(initial="100%", md="min(640px, 72vw)"), - background=PANEL, - border_left=f"1px solid {AMBER}", - z_index="70", - display="flex", - flex_direction="column", - ), - ), - rx.box(), - ) - - -def terminal_shell() -> rx.Component: - return rx.box( - rx.box( - rx.box( - rx.hstack( - rx.vstack( - rx.hstack( - rx.text( - "XY TERMINAL", - color=AMBER, - font_family=MONO, - font_size="18px", - font_weight="950", - white_space="nowrap", - ), - rx.text( - "MULTI-ASSET ANALYTICS", - color=MUTED, - font_family=MONO, - font_size="9px", - white_space="nowrap", - display=rx.breakpoints(initial="none", sm="block"), - ), - spacing="2", - align="center", - ), - rx.text( - "INDEPENDENT TERMINAL-STYLE DEMO · NO EXTERNAL MARKET FEED", - color=MUTED, - font_family=MONO, - font_size="8px", - white_space="nowrap", - display=rx.breakpoints(initial="none", md="block"), - ), - spacing="0", - align="start", - min_width="0", - ), - rx.spacer(), - rx.text( - f"AS OF {AS_OF}", - color=TEXT, - font_family=MONO, - font_size="9px", - white_space="nowrap", - flex_shrink="0", - ), - align="center", - width="100%", - padding="6px 8px", - ), - command_bar(), - padding="0 8px 7px", - background="#090a0a", - ), - ticker_tape(), - position="sticky", - top="0", - z_index="40", - background=INK, - ), - rx.grid( - rx.box( - rx.vstack(watchlist(), context_rail(), spacing="2", width="100%"), - min_width="0", - order=rx.breakpoints(initial="2", sm="1"), - display=rx.breakpoints(initial="block", lg="none"), - position=rx.breakpoints(initial="static", sm="sticky"), - top="112px", - max_height=rx.breakpoints(initial="none", sm="calc(100vh - 160px)"), - overflow_y=rx.breakpoints(initial="visible", sm="auto"), - scrollbar_width="thin", - ), - rx.box( - watchlist(), - min_width="0", - order="1", - display=rx.breakpoints(initial="none", lg="block"), - position="sticky", - top="112px", - ), - rx.box( - workspace(), - min_width="0", - overflow="hidden", - order=rx.breakpoints(initial="1", sm="2"), - ), - rx.box( - context_rail(), - min_width="0", - order="3", - display=rx.breakpoints(initial="none", lg="block"), - position="sticky", - top="112px", - ), - grid_template_columns=rx.breakpoints( - initial="minmax(0, 1fr)", - sm="180px minmax(0, 1fr)", - lg="210px minmax(0, 1fr) 225px", - ), - gap="8px", - width="100%", - padding="8px", - align_items="start", - flex="1", - ), - rx.box( - function_keys(), - rx.hstack( - rx.text( - TerminalState.command_status, - color=CYAN, - min_width="0", - overflow="hidden", - text_overflow="ellipsis", - white_space="nowrap", - ), - rx.spacer(), - rx.hstack( - rx.text("● LOCAL", color=GREEN, white_space="nowrap"), - rx.text( - "NO API KEY", - color=MUTED, - white_space="nowrap", - display=rx.breakpoints(initial="none", sm="block"), - ), - spacing="2", - flex_shrink="0", - ), - width="100%", - padding="3px 8px", - background="#080909", - font_family=MONO, - font_size="9px", - ), - position="sticky", - bottom="0", - z_index="40", - background="#080909", - ), - developer_drawer(), - background=INK, - color=TEXT, - min_height="100vh", - width="100%", - font_family=MONO, - display="flex", - flex_direction="column", - class_name="dark", - ) - - -def index() -> rx.Component: - return terminal_shell() - - -__all__ = [ - "YIELD_CURVE_TOKEN", - "context_rail", - "developer_drawer", - "index", - "markets_workspace", - "news_workspace", - "portfolio_workspace", - "risk_workspace", - "security_workspace", - "terminal_shell", -] diff --git a/examples/reflex/xy_reflex_demo/data.py b/examples/reflex/xy_reflex_demo/data.py deleted file mode 100644 index 0d9f6ff9..00000000 --- a/examples/reflex/xy_reflex_demo/data.py +++ /dev/null @@ -1,927 +0,0 @@ -"""Deterministic simulated market data for the XY terminal example. - -Nothing in this module reaches the network. Every quote, story, calendar -entry, portfolio value, and risk result is reproducible from the fixed -``AS_OF`` date and seeds below. Large NumPy columns live behind module-level -caches rather than in Reflex state. -""" - -from __future__ import annotations - -import math -from collections.abc import Mapping, Sequence -from dataclasses import asdict, dataclass -from datetime import date -from functools import cache, lru_cache -from typing import Any - -import numpy as np - -AS_OF = date(2026, 7, 31) -SIMULATED_DATA_LABEL = f"SIMULATED DATA · AS OF {AS_OF.isoformat()}" - -_AS_OF64 = np.datetime64(AS_OF.isoformat(), "D") -_START64 = np.datetime64("2023-08-01", "D") -_RANGE_DAYS = {"1M": 31, "3M": 92, "6M": 183, "1Y": 366} -_RESOLUTIONS = frozenset({"1D", "1W"}) -_RANGES = frozenset((*_RANGE_DAYS, "MAX")) - - -def _readonly(values: Any, *, dtype: Any = np.float64) -> np.ndarray: - array = np.ascontiguousarray(values, dtype=dtype) - array.setflags(write=False) - return array - - -@dataclass(frozen=True, slots=True) -class Instrument: - """Metadata and simulation parameters for a terminal security.""" - - symbol: str - name: str - asset_class: str - sector: str - currency: str - exchange: str - tick_size: float - price_decimals: int - base_price: float - annual_drift: float - annual_volatility: float - beta: float - base_volume: float - seed: int - - -@dataclass(frozen=True, slots=True) -class Quote: - symbol: str - name: str - asset_class: str - last: float - change: float - change_percent: float - open: float - high: float - low: float - volume: float - as_of: str = AS_OF.isoformat() - - -@dataclass(frozen=True, slots=True) -class Position: - symbol: str - quantity: float - average_cost: float - account: str = "SIM-PRIMARY" - - -@dataclass(frozen=True, slots=True) -class NewsItem: - id: str - timestamp: str - source: str - headline: str - summary: str - symbols: tuple[str, ...] - sentiment: str - impact: str - - -@dataclass(frozen=True, slots=True) -class CalendarEvent: - id: str - timestamp: str - country: str - event: str - importance: str - actual: str - forecast: str - previous: str - - -@dataclass(frozen=True, slots=True) -class OHLCV: - """Read-only aligned OHLCV columns.""" - - dates: np.ndarray - open: np.ndarray - high: np.ndarray - low: np.ndarray - close: np.ndarray - volume: np.ndarray - symbol: str - resolution: str - range_key: str - - def __post_init__(self) -> None: - dates = _readonly(self.dates, dtype="datetime64[D]") - columns = tuple( - _readonly(getattr(self, name)) for name in ("open", "high", "low", "close", "volume") - ) - size = len(dates) - if any(column.ndim != 1 or len(column) != size for column in columns): - raise ValueError("OHLCV columns must be aligned one-dimensional arrays") - if size and not all(np.isfinite(column).all() for column in columns): - raise ValueError("OHLCV columns must contain only finite values") - open_, high, low, close, volume = columns - if np.any(high < np.maximum(open_, close)) or np.any(low > np.minimum(open_, close)): - raise ValueError("OHLCV high/low invariants are violated") - if np.any(low <= 0) or np.any(volume < 0): - raise ValueError("OHLCV prices must be positive and volume non-negative") - object.__setattr__(self, "dates", dates) - for name, column in zip(("open", "high", "low", "close", "volume"), columns, strict=True): - object.__setattr__(self, name, column) - - @property - def x(self) -> np.ndarray: - return self.dates - - def __len__(self) -> int: - return len(self.dates) - - -@dataclass(frozen=True, slots=True) -class PortfolioSeries: - dates: np.ndarray - equity: np.ndarray - returns: np.ndarray - pnl: np.ndarray - - def __post_init__(self) -> None: - dates = _readonly(self.dates, dtype="datetime64[D]") - equity = _readonly(self.equity) - returns = _readonly(self.returns) - pnl = _readonly(self.pnl) - if ( - len(dates) != len(equity) - or len(pnl) != len(equity) - or len(returns) != max(0, len(equity) - 1) - ): - raise ValueError("portfolio series columns are not aligned") - if ( - not np.isfinite(equity).all() - or not np.isfinite(returns).all() - or not np.isfinite(pnl).all() - ): - raise ValueError("portfolio series must contain only finite values") - object.__setattr__(self, "dates", dates) - object.__setattr__(self, "equity", equity) - object.__setattr__(self, "returns", returns) - object.__setattr__(self, "pnl", pnl) - - -@dataclass(frozen=True, slots=True) -class ScenarioResult: - name: str - description: str - confidence: float - pnl: float - loss_percent: float - nav_after: float - - -INSTRUMENTS: Mapping[str, Instrument] = { - item.symbol: item - for item in ( - Instrument( - "SPY", - "S&P 500 ETF", - "Equity ETF", - "Broad Market", - "USD", - "ARCX", - 0.01, - 2, - 420.0, - 0.085, - 0.17, - 1.00, - 74_000_000, - 101, - ), - Instrument( - "AAPL", - "Apple Inc.", - "Equity", - "Technology", - "USD", - "XNAS", - 0.01, - 2, - 155.0, - 0.10, - 0.25, - 1.18, - 58_000_000, - 103, - ), - Instrument( - "MSFT", - "Microsoft Corp.", - "Equity", - "Technology", - "USD", - "XNAS", - 0.01, - 2, - 310.0, - 0.11, - 0.23, - 1.08, - 24_000_000, - 107, - ), - Instrument( - "NVDA", - "NVIDIA Corp.", - "Equity", - "Technology", - "USD", - "XNAS", - 0.01, - 2, - 44.0, - 0.18, - 0.48, - 1.62, - 310_000_000, - 109, - ), - Instrument( - "JPM", - "JPMorgan Chase", - "Equity", - "Financials", - "USD", - "XNYS", - 0.01, - 2, - 145.0, - 0.08, - 0.24, - 1.10, - 9_500_000, - 113, - ), - Instrument( - "XOM", - "Exxon Mobil", - "Equity", - "Energy", - "USD", - "XNYS", - 0.01, - 2, - 102.0, - 0.055, - 0.25, - 0.88, - 17_000_000, - 127, - ), - Instrument( - "EURUSD", - "Euro / U.S. Dollar", - "FX", - "G10 FX", - "USD", - "OTC", - 0.0001, - 4, - 1.09, - 0.002, - 0.085, - 0.08, - 5_200_000_000, - 131, - ), - Instrument( - "USDJPY", - "U.S. Dollar / Yen", - "FX", - "G10 FX", - "JPY", - "OTC", - 0.01, - 2, - 142.0, - 0.005, - 0.10, - 0.12, - 4_600_000_000, - 137, - ), - Instrument( - "XAUUSD", - "Gold Spot / U.S. Dollar", - "Commodity", - "Metals", - "USD", - "OTC", - 0.10, - 1, - 1_940.0, - 0.06, - 0.18, - 0.18, - 185_000, - 139, - ), - Instrument( - "BTCUSD", - "Bitcoin / U.S. Dollar", - "Crypto", - "Digital Assets", - "USD", - "24/7", - 0.10, - 1, - 29_000.0, - 0.20, - 0.62, - 1.25, - 31_000, - 149, - ), - ) -} - -_SYMBOLS = tuple(INSTRUMENTS) -_WATCHLIST = _SYMBOLS -_PORTFOLIO_CASH = 175_000.0 -_POSITIONS = ( - Position("SPY", 180.0, 468.20), - Position("AAPL", 240.0, 181.35), - Position("MSFT", 120.0, 374.60), - Position("NVDA", 480.0, 92.80), - Position("JPM", 190.0, 188.10), - Position("XOM", 260.0, 109.40), - Position("XAUUSD", 16.0, 2_180.0), - Position("BTCUSD", 0.75, 54_500.0), -) - - -def instrument_symbols() -> tuple[str, ...]: - return _SYMBOLS - - -def instrument(symbol: str) -> Instrument: - key = symbol.upper().replace("/", "").strip() - try: - return INSTRUMENTS[key] - except KeyError as exc: - raise ValueError(f"unknown simulated instrument {symbol!r}") from exc - - -@lru_cache(maxsize=1) -def _business_dates() -> np.ndarray: - dates = np.arange(_START64, _AS_OF64 + np.timedelta64(1, "D"), dtype="datetime64[D]") - dates = dates[np.is_busday(dates)] - return _readonly(dates, dtype="datetime64[D]") - - -@lru_cache(maxsize=1) -def _market_factors() -> tuple[np.ndarray, Mapping[str, np.ndarray]]: - n = len(_business_dates()) - rng = np.random.default_rng(20260802) - market = rng.standard_normal(n) - class_seeds = {"Equity ETF": 211, "Equity": 223, "FX": 227, "Commodity": 229, "Crypto": 233} - factors = { - name: np.random.default_rng(seed).standard_normal(n) for name, seed in class_seeds.items() - } - return _readonly(market), {name: _readonly(values) for name, values in factors.items()} - - -@cache -def _daily_history(symbol: str) -> OHLCV: - meta = instrument(symbol) - dates = _business_dates() - market, class_factors = _market_factors() - rng = np.random.default_rng(meta.seed) - idiosyncratic = rng.standard_normal(len(dates)) - raw = meta.beta * 0.46 * market + 0.34 * class_factors[meta.asset_class] + 0.72 * idiosyncratic - raw = (raw - float(np.mean(raw))) / float(np.std(raw)) - daily_sigma = meta.annual_volatility / math.sqrt(252.0) - log_returns = meta.annual_drift / 252.0 - 0.5 * daily_sigma**2 + daily_sigma * raw - close = meta.base_price * np.exp(np.cumsum(log_returns)) - - previous = np.concatenate(([meta.base_price], close[:-1])) - overnight = rng.normal(0.0, daily_sigma * 0.20, len(dates)) - open_ = previous * np.exp(overnight) - spread = np.maximum( - np.abs(rng.normal(daily_sigma * 0.48, daily_sigma * 0.16, len(dates))), - daily_sigma * 0.08, - ) - high = np.maximum(open_, close) * (1.0 + spread) - low = np.minimum(open_, close) * np.maximum( - 0.02, 1.0 - spread * rng.uniform(0.72, 1.12, len(dates)) - ) - volume = meta.base_volume * rng.lognormal(mean=-0.08, sigma=0.33, size=len(dates)) - volume *= 1.0 + np.minimum(np.abs(log_returns) / max(daily_sigma, 1e-12), 4.0) * 0.15 - return OHLCV(dates, open_, high, low, close, volume, meta.symbol, "1D", "MAX") - - -def _weekly(daily: OHLCV) -> OHLCV: - python_dates = daily.dates.astype(object) - week_keys = np.fromiter( - (value.isocalendar().year * 100 + value.isocalendar().week for value in python_dates), - dtype=np.int64, - count=len(python_dates), - ) - starts = np.flatnonzero(np.r_[True, week_keys[1:] != week_keys[:-1]]) - ends = np.r_[starts[1:], len(week_keys)] - return OHLCV( - daily.dates[ends - 1], - daily.open[starts], - np.asarray( - [np.max(daily.high[start:end]) for start, end in zip(starts, ends, strict=True)] - ), - np.asarray([np.min(daily.low[start:end]) for start, end in zip(starts, ends, strict=True)]), - daily.close[ends - 1], - np.asarray( - [np.sum(daily.volume[start:end]) for start, end in zip(starts, ends, strict=True)] - ), - daily.symbol, - "1W", - "MAX", - ) - - -def _slice_history(source: OHLCV, range_key: str) -> OHLCV: - if range_key == "MAX": - return source - cutoff = _AS_OF64 - np.timedelta64(_RANGE_DAYS[range_key], "D") - start = int(np.searchsorted(source.dates, cutoff, side="left")) - return OHLCV( - source.dates[start:], - source.open[start:], - source.high[start:], - source.low[start:], - source.close[start:], - source.volume[start:], - source.symbol, - source.resolution, - range_key, - ) - - -@cache -def _history_cached(symbol: str, resolution: str, range_key: str) -> OHLCV: - daily = _daily_history(symbol) - source = daily if resolution == "1D" else _weekly(daily) - return _slice_history(source, range_key) - - -def history(symbol: str, resolution: str = "1D", range_key: str = "MAX") -> OHLCV: - """Return cached read-only history for a supported symbol/resolution/range.""" - - key = instrument(symbol).symbol - normalized_resolution = resolution.upper().strip() - normalized_range = range_key.upper().strip() - if normalized_resolution not in _RESOLUTIONS: - raise ValueError(f"resolution must be one of {sorted(_RESOLUTIONS)}") - if normalized_range not in _RANGES: - raise ValueError(f"range_key must be one of {sorted(_RANGES)}") - return _history_cached(key, normalized_resolution, normalized_range) - - -@cache -def quote(symbol: str) -> Quote: - meta = instrument(symbol) - values = history(meta.symbol) - previous = float(values.close[-2]) - last = float(values.close[-1]) - change = last - previous - return Quote( - symbol=meta.symbol, - name=meta.name, - asset_class=meta.asset_class, - last=last, - change=change, - change_percent=change / previous * 100.0, - open=float(values.open[-1]), - high=float(values.high[-1]), - low=float(values.low[-1]), - volume=float(values.volume[-1]), - ) - - -def _quote_row(value: Quote) -> dict[str, Any]: - row = asdict(value) - row["direction"] = "UP" if value.change > 0 else "DOWN" if value.change < 0 else "FLAT" - row["last_display"] = f"{value.last:,.{instrument(value.symbol).price_decimals}f}" - row["change_display"] = f"{value.change:+,.{instrument(value.symbol).price_decimals}f}" - row["change_percent_display"] = f"{value.change_percent:+.2f}%" - return row - - -def watchlist_rows() -> tuple[dict[str, Any], ...]: - return tuple(_quote_row(quote(symbol)) for symbol in _WATCHLIST) - - -def movers(limit: int = 5) -> tuple[Quote, ...]: - if limit <= 0: - return () - values = sorted( - (quote(symbol) for symbol in _WATCHLIST), - key=lambda item: abs(item.change_percent), - reverse=True, - ) - return tuple(values[:limit]) - - -def breadth_metrics() -> dict[str, float | int]: - equity_symbols = tuple( - symbol - for symbol, meta in INSTRUMENTS.items() - if meta.asset_class in {"Equity", "Equity ETF"} - ) - quotes = tuple(quote(symbol) for symbol in equity_symbols) - above_20d = sum( - history(symbol, range_key="1M").close[-1] > np.mean(history(symbol, range_key="1M").close) - for symbol in equity_symbols - ) - advances = sum(value.change > 0 for value in quotes) - return { - "advancers": advances, - "decliners": len(quotes) - advances, - "advance_decline": float(advances - (len(quotes) - advances)), - "average_change_percent": float(np.mean([value.change_percent for value in quotes])), - "above_20d": int(above_20d), - "universe": len(quotes), - } - - -def market_heatmap_data() -> tuple[tuple[str, ...], tuple[str, ...], np.ndarray]: - ranges = ("1D", "1M", "3M") - matrix = np.empty((len(ranges), len(_SYMBOLS)), dtype=np.float64) - for row, range_key in enumerate(ranges): - for column, symbol in enumerate(_SYMBOLS): - values = history(symbol, range_key="MAX" if range_key == "1D" else range_key) - first = float(values.close[-2] if range_key == "1D" else values.close[0]) - matrix[row, column] = (float(values.close[-1]) / first - 1.0) * 100.0 - return _SYMBOLS, ranges, _readonly(matrix) - - -def yield_curve() -> tuple[tuple[str, ...], np.ndarray, np.ndarray]: - tenors = ("3M", "6M", "1Y", "2Y", "5Y", "10Y", "20Y", "30Y") - years = _readonly([0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 30.0]) - rates = _readonly([4.68, 4.49, 4.18, 3.96, 4.08, 4.31, 4.61, 4.52]) - return tenors, years, rates - - -@lru_cache(maxsize=1) -def pulse_seed() -> tuple[np.ndarray, np.ndarray]: - rng = np.random.default_rng(991) - x = np.arange(36, dtype=np.float64) - y = 100.0 + np.cumsum(rng.normal(0.0, 0.16, len(x))) - return _readonly(x), _readonly(y) - - -def positions() -> tuple[Position, ...]: - return _POSITIONS - - -def position_rows() -> tuple[dict[str, Any], ...]: - rows: list[dict[str, Any]] = [] - for item in _POSITIONS: - last = quote(item.symbol).last - market_value = item.quantity * last - cost = item.quantity * item.average_cost - pnl = market_value - cost - rows.append( - { - **asdict(item), - "last": last, - "market_value": market_value, - "cost_basis": cost, - "pnl": pnl, - "pnl_percent": pnl / cost * 100.0, - "pnl_pct": pnl / cost * 100.0, - } - ) - return tuple(rows) - - -def portfolio_summary() -> dict[str, float]: - rows = position_rows() - market_value = float(sum(row["market_value"] for row in rows)) - cost_basis = float(sum(row["cost_basis"] for row in rows)) - pnl = market_value - cost_basis - nav = _PORTFOLIO_CASH + market_value - return { - "cash": _PORTFOLIO_CASH, - "market_value": market_value, - "cost_basis": cost_basis, - "pnl": pnl, - "pnl_percent": pnl / cost_basis * 100.0, - "nav": nav, - "gross_exposure_percent": market_value / nav * 100.0, - } - - -@cache -def portfolio_equity(range_key: str = "MAX") -> PortfolioSeries: - normalized_range = range_key.upper().strip() - if normalized_range not in _RANGES: - raise ValueError(f"range_key must be one of {sorted(_RANGES)}") - source = history(_POSITIONS[0].symbol, range_key=normalized_range) - equity = np.full(len(source), _PORTFOLIO_CASH, dtype=np.float64) - for item in _POSITIONS: - equity += item.quantity * history(item.symbol, range_key=normalized_range).close - pnl = equity - equity[0] - returns = np.diff(equity) / equity[:-1] - return PortfolioSeries(source.dates, equity, returns, pnl) - - -def portfolio_returns(range_key: str = "1Y") -> np.ndarray: - return portfolio_equity(range_key).returns - - -def portfolio_allocation() -> tuple[tuple[str, ...], np.ndarray]: - rows = position_rows() - values = np.asarray([row["market_value"] for row in rows], dtype=np.float64) - return tuple(row["symbol"] for row in rows), _readonly(values / np.sum(values) * 100.0) - - -def portfolio_contribution() -> tuple[tuple[str, ...], np.ndarray]: - rows = position_rows() - return tuple(row["symbol"] for row in rows), _readonly([row["pnl"] for row in rows]) - - -def sector_exposures() -> tuple[tuple[str, ...], np.ndarray]: - grouped: dict[str, float] = {} - for row in position_rows(): - sector = instrument(row["symbol"]).sector - grouped[sector] = grouped.get(sector, 0.0) + float(row["market_value"]) - nav = portfolio_summary()["nav"] - return tuple(grouped), _readonly([value / nav * 100.0 for value in grouped.values()]) - - -def correlation_matrix( - symbols: Sequence[str] | None = None, range_key: str = "1Y" -) -> tuple[tuple[str, ...], np.ndarray]: - selected = tuple( - instrument(symbol).symbol - for symbol in (symbols or ("SPY", "AAPL", "MSFT", "NVDA", "JPM", "XOM")) - ) - if len(selected) < 2: - raise ValueError("correlation_matrix requires at least two symbols") - columns = [] - for symbol in selected: - close = history(symbol, range_key=range_key).close - columns.append(np.diff(close) / close[:-1]) - matrix = np.corrcoef(np.vstack(columns)) - if not np.isfinite(matrix).all(): - raise ValueError("correlation matrix contains non-finite values") - return selected, _readonly(matrix) - - -def factor_exposures() -> tuple[tuple[str, ...], np.ndarray]: - rows = position_rows() - total = sum(float(row["market_value"]) for row in rows) - weights = {row["symbol"]: float(row["market_value"]) / total for row in rows} - market = sum(weights[symbol] * instrument(symbol).beta for symbol in weights) - technology = sum( - weights[symbol] for symbol in weights if instrument(symbol).sector == "Technology" - ) - defensive = sum( - weights[symbol] for symbol in weights if instrument(symbol).sector in {"Energy", "Metals"} - ) - dollar = sum(weights[symbol] for symbol in weights if instrument(symbol).currency == "USD") - alternatives = sum( - weights[symbol] - for symbol in weights - if instrument(symbol).asset_class in {"Commodity", "Crypto"} - ) - labels = ("Market beta", "Technology", "Defensive", "USD", "Alternatives") - return labels, _readonly([market, technology, defensive, dollar, alternatives]) - - -def _normalize_confidence(value: float | int | str) -> float: - if isinstance(value, str): - value = float(value.strip().rstrip("%")) - confidence = float(value) - if confidence > 1.0: - confidence /= 100.0 - if confidence not in {0.95, 0.99}: - raise ValueError("confidence must be 0.95/95% or 0.99/99%") - return confidence - - -def stress_scenarios(confidence: float | int | str = 0.95) -> tuple[ScenarioResult, ...]: - normalized = _normalize_confidence(confidence) - multiplier = 1.0 if normalized == 0.95 else 1.18 - nav = portfolio_summary()["nav"] - values = {row["symbol"]: float(row["market_value"]) for row in position_rows()} - definitions: tuple[tuple[str, str, Mapping[str, float]], ...] = ( - ( - "Equity selloff", - "Broad risk assets gap lower; gold provides a partial hedge.", - { - "SPY": -0.12, - "AAPL": -0.16, - "MSFT": -0.15, - "NVDA": -0.24, - "JPM": -0.17, - "XOM": -0.10, - "XAUUSD": 0.045, - "BTCUSD": -0.27, - }, - ), - ( - "Rates +150 bp", - "Long-duration growth reprices while financials are comparatively resilient.", - { - "SPY": -0.07, - "AAPL": -0.10, - "MSFT": -0.11, - "NVDA": -0.16, - "JPM": -0.025, - "XOM": -0.04, - "XAUUSD": -0.08, - "BTCUSD": -0.13, - }, - ), - ( - "Energy shock", - "Oil-linked assets rally as margins and consumer risk deteriorate.", - { - "SPY": -0.045, - "AAPL": -0.04, - "MSFT": -0.035, - "NVDA": -0.06, - "JPM": -0.05, - "XOM": 0.18, - "XAUUSD": 0.025, - "BTCUSD": -0.07, - }, - ), - ( - "Dollar squeeze", - "USD liquidity pressure hits alternatives and multinational earnings.", - { - "SPY": -0.04, - "AAPL": -0.055, - "MSFT": -0.05, - "NVDA": -0.075, - "JPM": -0.03, - "XOM": -0.035, - "XAUUSD": -0.09, - "BTCUSD": -0.16, - }, - ), - ) - results = [] - for name, description, shocks in definitions: - pnl = multiplier * sum(values[symbol] * shock for symbol, shock in shocks.items()) - results.append( - ScenarioResult( - name=name, - description=description, - confidence=normalized, - pnl=float(pnl), - loss_percent=float(pnl / nav * 100.0), - nav_after=float(nav + pnl), - ) - ) - return tuple(results) - - -_STORIES = ( - NewsItem( - "N-001", - "2026-07-31T15:42:00Z", - "XY Wire", - "Semiconductor complex leads late-session rebound", - "A broad technology bid accelerated after systematic flows turned positive into the close. All values and events in this terminal are simulated.", - ("NVDA", "MSFT", "SPY"), - "Positive", - "High", - ), - NewsItem( - "N-002", - "2026-07-31T14:18:00Z", - "Terminal Research", - "Yield curve steepens as front-end expectations ease", - "The simulated curve bull-steepened after a softer activity proxy, while long-end term premium remained firm.", - ("SPY", "JPM", "XAUUSD"), - "Mixed", - "High", - ), - NewsItem( - "N-003", - "2026-07-31T12:05:00Z", - "Market Desk", - "Dollar pauses; gold holds above technical support", - "G10 FX volatility compressed and the fictional spot-gold series consolidated above its 20-day average.", - ("EURUSD", "USDJPY", "XAUUSD"), - "Neutral", - "Medium", - ), - NewsItem( - "N-004", - "2026-07-31T10:31:00Z", - "Digital Ledger", - "Crypto beta rises with broader risk appetite", - "Bitcoin's simulated realized volatility moved higher as cross-asset correlations strengthened.", - ("BTCUSD", "SPY", "NVDA"), - "Positive", - "Medium", - ), - NewsItem( - "N-005", - "2026-07-31T09:12:00Z", - "Energy Brief", - "Integrated energy shares lag despite firm commodity tape", - "Refining-margin concerns offset a fictional increase in spot energy benchmarks.", - ("XOM", "SPY"), - "Negative", - "Medium", - ), - NewsItem( - "N-006", - "2026-07-30T20:45:00Z", - "Global Close", - "Asia handoff points to cautious open", - "Index futures were little changed in the deterministic overnight scenario; no live venue data is used.", - ("SPY", "USDJPY"), - "Neutral", - "Low", - ), -) - -_CALENDAR = ( - CalendarEvent( - "C-001", "2026-08-03T14:00:00Z", "US", "ISM Manufacturing", "High", "--", "49.8", "49.2" - ), - CalendarEvent( - "C-002", "2026-08-04T04:30:00Z", "AU", "RBA Rate Decision", "High", "--", "3.60%", "3.60%" - ), - CalendarEvent( - "C-003", - "2026-08-05T12:15:00Z", - "US", - "ADP Employment Change", - "Medium", - "--", - "118K", - "105K", - ), - CalendarEvent( - "C-004", "2026-08-06T11:00:00Z", "GB", "BoE Bank Rate", "High", "--", "3.75%", "4.00%" - ), - CalendarEvent( - "C-005", "2026-08-07T12:30:00Z", "US", "Nonfarm Payrolls", "High", "--", "165K", "142K" - ), -) - - -def stories(symbol: str | None = None) -> tuple[NewsItem, ...]: - if symbol is None: - return _STORIES - key = instrument(symbol).symbol - return tuple(item for item in _STORIES if key in item.symbols) - - -def calendar_events() -> tuple[CalendarEvent, ...]: - return _CALENDAR - - -__all__ = [ - "AS_OF", - "INSTRUMENTS", - "OHLCV", - "SIMULATED_DATA_LABEL", - "CalendarEvent", - "Instrument", - "NewsItem", - "PortfolioSeries", - "Position", - "Quote", - "ScenarioResult", - "breadth_metrics", - "calendar_events", - "correlation_matrix", - "factor_exposures", - "history", - "instrument", - "instrument_symbols", - "market_heatmap_data", - "movers", - "portfolio_allocation", - "portfolio_contribution", - "portfolio_equity", - "portfolio_returns", - "portfolio_summary", - "position_rows", - "positions", - "pulse_seed", - "quote", - "sector_exposures", - "stories", - "stress_scenarios", - "watchlist_rows", - "yield_curve", -] diff --git a/examples/reflex/xy_reflex_demo/state.py b/examples/reflex/xy_reflex_demo/state.py deleted file mode 100644 index a9b4b088..00000000 --- a/examples/reflex/xy_reflex_demo/state.py +++ /dev/null @@ -1,439 +0,0 @@ -"""Small Reflex state surface for the XY terminal example. - -Large market series live in :mod:`.data`'s module-level caches. This state -only records the user's current terminal selections and small interaction -readouts; chart builders resolve the cached arrays when a figure var changes. -""" - -from __future__ import annotations - -import asyncio -import math -from typing import Any - -import reflex as rx - -import reflex_xy - -from . import charts, data - -WORKSPACES = ("MARKETS", "SECURITY", "PORTFOLIO", "RISK", "NEWS") -RANGES = ("1M", "3M", "6M", "1Y", "MAX") -RESOLUTIONS = ("1D", "1W") -OVERLAYS = ("SMA 20", "EMA 50", "Bollinger", "VWAP", "Anchored VWAP", "Volume Profile") -OSCILLATORS = ("None", "RSI", "MACD", "Stochastic") -DRAWINGS = ( - "None", - "Long position", - "Short position", - "Forecast", - "Bars pattern", - "Ghost feed", - "XABCD", -) -CONFIDENCE_LEVELS = ("95%", "99%") - - -def _number(value: str) -> float | None: - """Parse a finite terminal input without allowing NaN/inf downstream.""" - try: - result = float(value) - except (TypeError, ValueError): - return None - return result if math.isfinite(result) else None - - -def _symbols() -> tuple[str, ...]: - return tuple(str(symbol).upper() for symbol in data.instrument_symbols()) - - -_TAPE_BASE = tuple( - ( - str(row["symbol"]), - float(row["last"]), - float(row["change_percent"]), - int(data.instrument(str(row["symbol"])).price_decimals), - ) - for row in data.watchlist_rows() -) - - -def _tape_rows(step: int) -> list[dict[str, str]]: - """Return ten compact, deterministic display rows for the live tape.""" - rows: list[dict[str, str]] = [] - for index, (symbol, baseline, base_change, decimals) in enumerate(_TAPE_BASE): - wobble = math.sin((step + index * 2.0) / 5.0) * 0.0012 - wobble += math.sin((step + index * 7.0) / 13.0) * 0.0005 - last = baseline * (1.0 + wobble) - change = base_change + wobble * 100.0 - rows.append( - { - "symbol": symbol, - "last": f"{last:,.{decimals}f}", - "change": f"{change:+.2f}%", - "direction": "UP" if change >= 0 else "DOWN", - } - ) - return rows - - -def _ticket_prices(symbol: str, side: str) -> tuple[str, str, str]: - quote = data.quote(symbol) - decimals = data.instrument(symbol).price_decimals - entry = float(quote.last) - if side == "Long": - stop, target = entry * 0.97, entry * 1.06 - else: - stop, target = entry * 1.03, entry * 0.94 - return tuple(f"{value:.{decimals}f}" for value in (entry, stop, target)) - - -_INITIAL_ENTRY, _INITIAL_STOP, _INITIAL_TARGET = _ticket_prices("AAPL", "Long") - - -class TerminalState(rx.State): - """Interaction state for the single-page terminal shell.""" - - workspace: str = "MARKETS" - command: str = "" - command_status: str = "READY — TRY MKTS, DES AAPL, PORT, RISK, NEWS, OR HELP" - help_visible: bool = False - - selected_symbol: str = "AAPL" - range_key: str = "6M" - resolution: str = "1D" - overlays: list[str] = ["SMA 20", "VWAP"] - oscillator: str = "RSI" - drawing: str = "Long position" - - hovered: dict[str, Any] = {} - view_status: str = "FULL HISTORY" - - streaming: bool = False - _stream_step: int = 35 - _stream_generation: int = 0 - tape_quotes: list[dict[str, str]] = _tape_rows(35) - - ticket_side: str = "Long" - ticket_entry: str = _INITIAL_ENTRY - ticket_stop: str = _INITIAL_STOP - ticket_target: str = _INITIAL_TARGET - ticket_account: str = "100000" - ticket_risk: str = "1.00" - - confidence_label: str = "95%" - selected_scenario: str = "Equity selloff" - selected_story: str = "N-001" - - developer_open: bool = False - developer_tab: str = "SOURCE" - - def _raw_ticket(self) -> dict[str, Any]: - return { - "symbol": self.selected_symbol, - "side": self.ticket_side.lower(), - "entry": _number(self.ticket_entry), - "stop": _number(self.ticket_stop), - "target": _number(self.ticket_target), - "account_size": _number(self.ticket_account), - "risk_percent": _number(self.ticket_risk), - } - - def _ticket_result(self) -> dict[str, Any]: - payload = self._raw_ticket() - if any(value is None for key, value in payload.items() if key not in {"side", "symbol"}): - return {"valid": False, "error": "ENTER FINITE NUMERIC TICKET VALUES"} - try: - return dict(charts.ticket_metrics(payload)) - except (TypeError, ValueError, ZeroDivisionError) as exc: - return {"valid": False, "error": str(exc).upper()} - - def _reset_ticket_prices(self) -> None: - self.ticket_entry, self.ticket_stop, self.ticket_target = _ticket_prices( - self.selected_symbol, self.ticket_side - ) - - @rx.var - def confidence(self) -> float: - return 0.99 if self.confidence_label == "99%" else 0.95 - - @rx.var - def ticket_valid(self) -> bool: - return bool(self._ticket_result().get("valid")) - - @rx.var - def ticket_error(self) -> str: - result = self._ticket_result() - return "" if result.get("valid") else str(result.get("error") or "INVALID TICKET") - - @rx.var - def ticket_risk_amount(self) -> str: - value = self._ticket_result().get("risk_amount") - return "—" if value is None else f"${float(value):,.2f}" - - @rx.var - def ticket_position_size(self) -> str: - value = self._ticket_result().get("quantity") - return "—" if value is None else f"{float(value):,.2f}" - - @rx.var - def ticket_reward_risk(self) -> str: - value = self._ticket_result().get("risk_reward") - return "—" if value is None else f"{float(value):.2f}×" - - @rx.var - def state_snapshot(self) -> str: - return ( - f"workspace={self.workspace}\n" - f"symbol={self.selected_symbol} range={self.range_key} resolution={self.resolution}\n" - f"overlays={','.join(self.overlays) or 'none'}\n" - f"oscillator={self.oscillator} drawing={self.drawing}\n" - f"confidence={self.confidence_label} scenario={self.selected_scenario}\n" - f"streaming={self.streaming}" - ) - - @reflex_xy.figure - def security_figure(self): - return charts.security_chart( - self.selected_symbol, - range_key=self.range_key, - resolution=self.resolution, - overlays=tuple(self.overlays), - oscillator=self.oscillator, - drawing=self.drawing, - ticket=self._raw_ticket(), - ) - - @reflex_xy.figure - def portfolio_figure(self): - return charts.portfolio_performance_chart() - - @reflex_xy.figure - def risk_figure(self): - return charts.risk_distribution_chart(self.confidence) - - @reflex_xy.figure - def market_pulse(self): - return charts.market_pulse_chart() - - @rx.event - def choose_workspace(self, workspace: str): - target = workspace.strip().upper() - if target in WORKSPACES: - self.workspace = target - self.command_status = f"{target} WORKSPACE" - self.help_visible = False - - @rx.event - def set_command(self, value: str): - self.command = value - - def _execute_command(self) -> None: - raw = self.command.strip() - self.help_visible = False - if not raw: - self.command_status = "ENTER A COMMAND — HELP LISTS AVAILABLE FUNCTIONS" - self.command = "" - return - parts = raw.upper().split() - verb = parts[0] - if verb == "MKTS" and len(parts) == 1: - self.workspace = "MARKETS" - self.command_status = "MKTS — GLOBAL MARKET MONITOR" - elif verb == "DES" and len(parts) == 2: - symbol = parts[1] - if symbol in _symbols(): - self.selected_symbol = symbol - self._reset_ticket_prices() - self.workspace = "SECURITY" - self.command_status = f"DES {symbol} — SECURITY DESCRIPTION" - else: - self.command_status = f"UNKNOWN SECURITY: {symbol}" - elif verb in {"PORT", "RISK", "NEWS"} and len(parts) == 1: - self.workspace = {"PORT": "PORTFOLIO", "RISK": "RISK", "NEWS": "NEWS"}[verb] - self.command_status = f"{verb} — {self.workspace} WORKSPACE" - elif verb == "HELP" and len(parts) == 1: - self.help_visible = True - self.command_status = "HELP — MKTS · DES · PORT · RISK · NEWS" - else: - self.command_status = f"UNKNOWN COMMAND: {raw.upper()} — TYPE HELP" - self.command = "" - - @rx.event - def execute_command(self): - self._execute_command() - - @rx.event - def command_key(self, key: str): - if key == "Enter": - self._execute_command() - - @rx.event - def select_symbol(self, symbol: str): - candidate = symbol.upper() - if candidate not in _symbols(): - self.command_status = f"UNKNOWN SECURITY: {candidate}" - return - self.selected_symbol = candidate - self._reset_ticket_prices() - self.workspace = "SECURITY" - self.command_status = f"DES {candidate} — SECURITY DESCRIPTION" - - @rx.event - def set_range_key(self, value: str): - if value in RANGES: - self.range_key = value - - @rx.event - def set_resolution(self, value: str): - if value in RESOLUTIONS: - self.resolution = value - - @rx.event - def toggle_overlay(self, overlay: str): - if overlay not in OVERLAYS: - return - if overlay in self.overlays: - self.overlays = [item for item in self.overlays if item != overlay] - else: - self.overlays = [*self.overlays, overlay] - - @rx.event - def set_oscillator(self, value: str): - if value in OSCILLATORS: - self.oscillator = value - - @rx.event - def set_drawing(self, value: str): - if value in DRAWINGS: - self.drawing = value - if value in {"Long position", "Short position"}: - self.ticket_side = "Long" if value == "Long position" else "Short" - self._reset_ticket_prices() - - @rx.event - def set_ticket_side(self, value: str): - if value in {"Long", "Short"}: - self.ticket_side = value - self._reset_ticket_prices() - if self.drawing in {"Long position", "Short position"}: - self.drawing = f"{value} position" - - @rx.event - def set_ticket_entry(self, value: str): - self.ticket_entry = value - - @rx.event - def set_ticket_stop(self, value: str): - self.ticket_stop = value - - @rx.event - def set_ticket_target(self, value: str): - self.ticket_target = value - - @rx.event - def set_ticket_account(self, value: str): - self.ticket_account = value - - @rx.event - def set_ticket_risk(self, value: str): - self.ticket_risk = value - - @rx.event - def drilldown_position(self, symbol: str): - self.selected_symbol = symbol.upper() - self._reset_ticket_prices() - self.workspace = "SECURITY" - self.command_status = f"PORT → DES {self.selected_symbol}" - - @rx.event - def set_confidence(self, value: str): - if value in CONFIDENCE_LEVELS: - self.confidence_label = value - - @rx.event - def set_scenario(self, value: str): - self.selected_scenario = value - - @rx.event - def select_story(self, story_id: str): - self.selected_story = story_id - - @rx.event - def toggle_developer(self): - self.developer_open = not self.developer_open - - @rx.event - def set_developer_tab(self, tab: str): - if tab in {"SOURCE", "STATE", "SPEC"}: - self.developer_tab = tab - - @rx.event - def on_chart_hover(self, event: dict[str, Any]): - """Reduce the structured hover payload to a compact terminal readout.""" - if not event.get("active"): - self.hovered = {} - return - points = event.get("points") or [] - point = points[0] if points else {} - row = point.get("row") or {} - cursor = (event.get("cursor") or {}).get("data") or {} - x_axis = str(point.get("x_axis") or "x") - y_axis = str(point.get("y_axis") or "y") - self.hovered = { - "x": row.get("x", cursor.get(x_axis, "—")), - "y": row.get("y", cursor.get(y_axis, "—")), - "trace": point.get("trace", "cursor"), - } - - @rx.event - def on_chart_view(self, event: reflex_xy.ViewChangeEvent): - x_domain = event.get("x_domain") or [] - if len(x_domain) == 2: - self.view_status = f"VIEW {float(x_domain[0]):.2f} → {float(x_domain[1]):.2f}" - else: - self.view_status = "VIEW UPDATED" - - @rx.event(background=True) - async def stream_quotes(self): - """Start/stop the single market-pulse producer for this state token.""" - async with self: - self._stream_generation += 1 - generation = self._stream_generation - if self.streaming: - self.streaming = False - return - self.streaming = True - token = self.market_pulse - while True: - async with self: - if ( - not self.streaming - or generation != self._stream_generation - or token != self.market_pulse - ): - break - self._stream_step += 1 - step = self._stream_step - self.tape_quotes = _tape_rows(step) - value = 100.0 + math.sin(step / 3.25) * 0.8 + math.sin(step / 11.0) * 1.4 - reflex_xy.append(token, x=[float(step)], y=[float(value)]) - await asyncio.sleep(0.8) - - -# A short alias keeps the example approachable in live notebooks and avoids -# breaking links that imported the former showcase's state class. -Demo = TerminalState - - -__all__ = [ - "CONFIDENCE_LEVELS", - "DRAWINGS", - "OSCILLATORS", - "OVERLAYS", - "RANGES", - "RESOLUTIONS", - "WORKSPACES", - "Demo", - "TerminalState", -] diff --git a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py index 723f0ea0..160b9b0e 100644 --- a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py +++ b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py @@ -1,17 +1,722 @@ -"""XY Terminal: a deterministic multi-workspace Reflex example. +"""XY Reflex showcase: ways to link chart data into a Reflex app. -Run from ``examples/reflex`` with ``uv run reflex run``. The page uses no -runtime network services or API keys; every market value is simulated. +One page of six sections; each has a "Code" accordion showing its own source +via `inspect.getsource`. + +1. **Live figure var + events.** A 1M-point drillable scatter from an + ``@reflex_xy.figure`` state method; its data rides the app websocket while + Reflex state holds only the token. Hover, click, and box-select arrive as + ordinary Reflex events. +2. **A chart driven by state vars.** A histogram whose bin count is a slider var + and whose data is cross-filtered by §1's box-selection; changing either + recomputes the figure and re-publishes it under a stable token. +3. **A dynamically updating chart.** A line grown from a background task via + ``reflex_xy.append``. +4. **Data computed from ``on_view_change``.** Pan/zoom an overview scatter; a + detail figure recomputes from the window the view-change event reports. +5. **Fixed data, two ways.** A ``xy.Chart`` passed straight to + ``reflex_xy.chart`` (static payload tier) and a ``reflex_xy.inline`` token + (fixed data served through the kernel). +6. **The drilldown, adapter-native.** The 100M-point live drilldown + scatter from ``examples/fastapi`` — identical data and mark config — as one + ``reflex_xy.inline`` token with zero transport code, for A/B-ing the two + hosts. ``XY_LIVE_POINTS`` resizes it (both apps honor the same override). +7. **Legend hover-highlight and click-to-toggle.** Left: named series on the + direct tier — hovering a legend row dims the others, clicking hides a + series entirely client-side (interaction spec §9/§10). Right: a categorical + density scatter behind an ``inline()`` token — clicking a category row + sends ``legend_toggle`` over the app websocket and the kernel re-bins the + surface with that category masked out (§34). + +Run from ``examples/reflex``:: + + uv run reflex run """ from __future__ import annotations +import asyncio +import inspect +import os +import warnings +from functools import lru_cache +from typing import Any + +import numpy as np import reflex as rx -from .components import index -from .state import Demo, TerminalState +import reflex_xy +import xy +from reflex_xy.tokens import BUILDER_ATTR + +POINTS = 1_000_000 +RNG_SEED = 11 + + +# --- shared source data ----------------------------------------------------- +# Shared columns are built once at module scope and cached; the figure builders +# read them and stay pure functions of state. + + +@lru_cache(maxsize=1) +def _cloud(n: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + rng = np.random.default_rng(RNG_SEED) + x = rng.normal(0.0, 1.0, n) + y = x * 0.55 + rng.normal(0.0, 0.55, n) + return x, y, np.hypot(x, y) + + +@lru_cache(maxsize=1) +def _scan(n: int) -> tuple[np.ndarray, np.ndarray]: + """An overview cloud whose y-distribution varies along x, so zooming into + different x-windows yields a visibly different detail histogram.""" + rng = np.random.default_rng(5) + x = rng.uniform(0.0, 100.0, n) + y = np.sin(x / 6.0) * 12.0 + x * 0.15 + rng.normal(0.0, 4.0, n) + return x, y + + +async def _magnitudes() -> tuple[np.ndarray, np.ndarray]: + """Async data source for the histogram builder; awaits like a database or + HTTP fetch would.""" + await asyncio.sleep(0) + x, _, mag = _cloud(POINTS) + return x, mag + + +# --- fixed-data charts (module scope) --------------------------------------- + + +def sparkline_chart() -> xy.Chart: + """A fixed chart passed directly to ``reflex_xy.chart``, which compiles it + to a static payload asset.""" + t = np.linspace(0.0, 6.0 * np.pi, 4000) + decay = np.exp(-t / 9.0) + return xy.line_chart( + xy.line(t, np.sin(t) * decay, name="signal"), + xy.line(t, decay, name="envelope"), + xy.x_axis(label="t"), + title="static payload tier", + width="100%", + height=240, + ) + + +def orbits_chart() -> xy.Chart: + """Fixed data registered with ``reflex_xy.inline`` and served through the + kernel for hover/pick under a content-addressed token.""" + rng = np.random.default_rng(3) + n = 400_000 + theta = rng.uniform(0.0, 2.0 * np.pi, n) + r = rng.normal(1.0, 0.05, n) * (1.0 + 0.4 * np.sin(theta * 3.0)) + return xy.scatter_chart( + xy.scatter(r * np.cos(theta), r * np.sin(theta), opacity=0.6, density=True), + xy.x_axis(label="x"), + xy.y_axis(label="y"), + title="inline() token", + width="100%", + height=240, + ) + + +# Registered at import; the content-addressed token resolves on any backend +# worker. +ORBITS_TOKEN = reflex_xy.inline(orbits_chart()) + + +# --- legend interactivity (§7) ---------------------------------------------- + + +def legend_series_chart() -> xy.Chart: + """Three named series on the direct tier. Hovering a legend row dims the + other series; clicking a row hides its series — a pure client hide (0 wire + bytes), so both work even on this static-payload chart. Defaults are on; + ``xy.legend(highlight=False)`` / ``xy.legend(toggle=False)`` opt out.""" + rng = np.random.default_rng(7) + marks = [ + xy.scatter( + rng.normal(cx, 0.5, 60_000), + rng.normal(cy, 0.5, 60_000), + name=name, + opacity=0.7, + ) + for name, cx, cy in ( + ("baseline", -1.5, -0.8), + ("candidate", 0.0, 0.9), + ("control", 1.6, -0.4), + ) + ] + return xy.scatter_chart( + *marks, + xy.legend(), + xy.x_axis(label="x"), + xy.y_axis(label="y"), + title="named series — hover dims, click hides", + width="100%", + height=300, + ) + + +def legend_category_chart() -> xy.Chart: + """One categorical density scatter: a legend row per category. Clicking a + row sends ``legend_toggle`` over the app websocket; the kernel drops that + category before re-binning the density surface (§34 — the reply's binning + is tagged ``-masked``) and the retained sample overlay filters instantly + while the re-bin is in flight.""" + rng = np.random.default_rng(21) + n = 1_200_000 + cat = rng.integers(0, 3, n) + centers = np.array([[-1.2, -0.6], [0.2, 1.1], [1.5, -0.9]]) + x = rng.normal(centers[cat, 0], 0.55) + y = rng.normal(centers[cat, 1], 0.55) + labels = np.array(["sensor A", "sensor B", "sensor C"])[cat] + return xy.scatter_chart( + xy.scatter(x, y, color=labels, opacity=0.7, density=True), + xy.legend(), + xy.x_axis(label="x"), + xy.y_axis(label="y"), + title="categorical density — click a row to mask & re-bin", + width="100%", + height=300, + ) + + +# Kernel-served so category toggles reach `legend_toggle` and the masked +# re-bin path; a static payload would only filter the local sample overlay. +LEGEND_CATS_TOKEN = reflex_xy.inline(legend_category_chart()) + + +# --- the live drilldown, adapter-native (§6) -------------------------- + + +def _drilldown_points() -> int: + """Point count for the §6 drilldown chart, from ``XY_LIVE_POINTS`` — the + same override ``examples/fastapi`` honors, so both apps build the identical + dataset at any size.""" + raw = os.environ.get("XY_LIVE_POINTS") + if raw is None: + return 100_000_000 + try: + points = int(raw) + except ValueError: + points = 0 + if points < 1: + warnings.warn( + f"XY_LIVE_POINTS={raw!r} is not a positive integer; using 100,000,000", + RuntimeWarning, + stacklevel=2, + ) + return 100_000_000 + return points + + +DRILLDOWN_POINTS = _drilldown_points() + + +def _point_label(n: int) -> str: + if n % 1_000_000 == 0: + return f"{n // 1_000_000}M" + if n % 1_000 == 0: + return f"{n // 1_000}k" + return f"{n:,}" + + +def drilldown_chart(n: int = DRILLDOWN_POINTS) -> xy.Chart: + """The ``examples/fastapi`` live-drilldown scatter: same seed, same chunked + generation, same mark config. That app wires the chart through its own + HTTP transport (a Starlette endpoint plus a comm bridge); here the + kernel's density tiers answer every pan/zoom over the app websocket.""" + rng = np.random.default_rng(11) + x = np.empty(n, dtype=np.float64) + y = np.empty(n, dtype=np.float64) + color = np.empty(n, dtype=np.float64) + size = np.empty(n, dtype=np.float64) + chunk = 1_000_000 + for start in range(0, n, chunk): + end = min(start + chunk, n) + xs = rng.normal(0, 1.0, end - start) + ys = rng.normal(0, 0.55, end - start) + ys += xs * 0.55 + ss = rng.normal(6, 2.5, end - start) + np.abs(ss, out=ss) + np.clip(ss, 2, 16, out=ss) + x[start:end] = xs + y[start:end] = ys + np.hypot(xs, ys, out=color[start:end]) + size[start:end] = ss + return xy.scatter_chart( + xy.scatter(x, y, color=color, size=size, colormap="viridis", opacity=0.72, density=True), + xy.x_axis(label="feature A"), + xy.y_axis(label="feature B"), + title=f"{_point_label(n)} live drilldown scatter", + width="100%", + height=430, + ) + + +# One shared kernel-backed figure for every viewer, expressed as a single +# inline() token; the registry keeps it process-global. +DRILLDOWN_TOKEN = reflex_xy.inline(drilldown_chart()) + + +# --- state ------------------------------------------------------------------ -app = rx.App() -app.add_page(index, title="XY Terminal · Simulated Markets") -__all__ = ["Demo", "TerminalState", "app", "index"] +class Demo(rx.State): + """Charts are figure vars; everything else is ordinary app state.""" + + # §1 semantic events + hovered: dict = {} + clicked: dict = {} + click_events: int = 0 + select_events: int = 0 + # Click/select handlers bump this and the cloud's title reads it, so every + # event deliberately republishes the source figure behind its stable + # token. The wrapper must keep the viewport and selection across that + # republish without re-dispatching events (no feedback loop) — the + # counters above make a violation visible as a runaway count. + interaction_revision: int = 0 + # §2 state-driven + cross-filter + bins: int = 60 + sel_active: bool = False + sel_x0: float = 0.0 + sel_x1: float = 0.0 + select_note: str = "box-select on the scatter to cross-filter the histogram" + # §3 streaming + streaming: bool = False + _stream_t: float = 0.0 + # §4 viewport-computed detail + view_ready: bool = False + view_x0: float = 0.0 + view_x1: float = 0.0 + visible: int = 0 + + @reflex_xy.figure + def cloud(self) -> xy.Chart: + x, y, mag = _cloud(POINTS) + return xy.scatter_chart( + xy.scatter(x, y, color=mag, colormap="viridis", opacity=0.8, density=True), + # hover and click are off by default; enable them so the point + # events reach the handlers below (select/pan/zoom are on already). + xy.interaction_config(hover=True, click=True), + xy.x_axis(label="feature A"), + xy.y_axis(label="feature B"), + title=( + f"{POINTS // 1_000_000}M points, drillable · " + f"handler revision {self.interaction_revision}" + ), + width="100%", + height=460, + ) + + @reflex_xy.figure + async def histogram(self) -> xy.Chart: + # Reads `bins` and the selection window; changing either re-publishes + # the figure. The async builder may await a data source. + x, mag = await _magnitudes() + if self.sel_active and self.sel_x1 > self.sel_x0: + mag = mag[(x >= self.sel_x0) & (x <= self.sel_x1)] + label = "selection" if self.sel_active else "all points" + return xy.histogram_chart( + xy.histogram(mag, bins=self.bins), + xy.x_axis(label=f"magnitude ({label})"), + title=f"magnitude distribution — {self.bins} bins", + width="100%", + height=240, + ) + + @reflex_xy.figure + def live(self) -> xy.Chart: + return xy.line_chart( + xy.line(np.array([0.0]), np.array([0.0])), + title="live stream", + width="100%", + height=240, + ) + + @reflex_xy.figure + def overview(self) -> xy.Chart: + x, y = _scan(120_000) + return xy.scatter_chart( + xy.scatter(x, y, opacity=0.5, density=True), + xy.interaction_config(zoom_axes=("x",)), + xy.x_axis(label="t"), + xy.y_axis(label="value"), + title="overview — zoom the x range", + width="100%", + height=240, + ) + + @reflex_xy.figure + def detail(self) -> xy.Chart: + # Recomputed from the window the overview last reported through + # `on_view_change`: a histogram of only the y-values currently in view. + x, y = _scan(120_000) + if self.view_ready and self.view_x1 > self.view_x0: + y = y[(x >= self.view_x0) & (x <= self.view_x1)] + title = ( + f"detail — {y.size:,} points in view" + if self.view_ready + else "detail — pan/zoom the overview" + ) + return xy.histogram_chart( + xy.histogram(y, bins=48, color="#7c3aed"), + xy.x_axis(label="value in view"), + title=title, + width="100%", + height=240, + ) + + @rx.event + def on_hover(self, event: reflex_xy.PointHoverEvent): + # v1 point envelope: canonical_row_id + f64 data coordinates. + self.hovered = event.get("data", {}) + + @rx.event + def on_click(self, event: reflex_xy.PointClickEvent): + self.click_events += 1 + self.interaction_revision += 1 + modifiers = event.get("modifiers", {}) + self.clicked = { + "row": event.get("canonical_row_id"), + **event.get("data", {}), + "modifiers": ",".join(k for k, v in modifiers.items() if v) or "none", + } + + @rx.event + def on_select(self, event: reflex_xy.SelectEndEvent): + self.select_events += 1 + self.interaction_revision += 1 + selection = event.get("selection", {}) + total = int(selection.get("total_count") or 0) + bounds = selection.get("data_bounds") or {} + if total and bounds.get("x0") is not None: + self.sel_x0 = float(bounds["x0"]) + self.sel_x1 = float(bounds["x1"]) + self.sel_active = True + self.select_note = ( + f"{total:,} selected · {len(selection.get('rows', [])):,} rows in JSON · " + f"truncated={bool(selection.get('truncated'))}" + ) + else: + self.sel_active = False + self.select_note = "selection cleared" + + @rx.event + def set_bins(self, value: list[int | float]): + self.bins = int(value[0]) + + @rx.event + def on_view(self, event: reflex_xy.ViewChangeEvent): + # `event` is the v1 view-change envelope; `x_domain` is the reported + # [x0, x1] window (throttled by the wrapper, streaming during the + # gesture). Store the window; the `detail` figure var depends on it + # and recomputes. + x_domain = event.get("x_domain") or [0.0, 0.0] + self.view_x0 = float(x_domain[0]) + self.view_x1 = float(x_domain[1]) + self.view_ready = True + x, _ = _scan(120_000) + self.visible = int(((x >= self.view_x0) & (x <= self.view_x1)).sum()) + + @rx.event(background=True) + async def stream(self): + async with self: + if self.streaming: + self.streaming = False + return + self.streaming = True + token = self.live + while True: + async with self: + if not self.streaming or token != self.live: + break + self._stream_t += 1.0 + t = self._stream_t + reflex_xy.append( + token, + x=[t], + y=[float(np.sin(t / 9.0) * 4.0 + np.random.default_rng(int(t)).normal(0, 0.4))], + ) + await asyncio.sleep(0.25) + + +# --- introspection: the "Code" accordions ----------------------------------- + + +def _source(obj: Any) -> str: + """Source of a plain function, an ``@reflex_xy.figure`` var, or an + ``@rx.event`` handler.""" + fget = getattr(obj, "_fget", None) + if fget is not None: # a @reflex_xy.figure / computed var + builder = getattr(fget, BUILDER_ATTR, None) + return inspect.getsource(builder if builder is not None else fget) + handler = getattr(obj, "fn", None) + if handler is not None: # an @rx.event handler + return inspect.getsource(handler) + return inspect.getsource(obj) + + +def code_accordion(*objs: Any) -> rx.Component: + source = "\n\n".join(inspect.cleandoc("\n" + _source(obj)) for obj in objs) + return rx.el.details( + rx.el.summary( + "Code", + cursor="pointer", + padding="0.75rem 1rem", + font_weight="700", + font_size="0.85rem", + list_style="none", + ), + rx.el.pre( + rx.el.code(source), + margin="0", + padding="1rem 1.15rem", + background="#0b1120", + color="#e5e7eb", + font_size="0.78rem", + line_height="1.55", + overflow_x="auto", + white_space="pre", + border_top="1px solid rgba(148,163,184,0.2)", + ), + border_top="1px solid var(--gray-5)", + width="100%", + ) + + +# --- layout ----------------------------------------------------------------- + + +def section(title: str, blurb: str, body: rx.Component, code: rx.Component) -> rx.Component: + return rx.box( + rx.box( + rx.heading(title, size="5"), + rx.text(blurb, color_scheme="gray", size="2", margin_top="0.25rem"), + padding="1rem 1.15rem", + ), + rx.box(body, padding="0 1.15rem 1.15rem"), + code, + border="1px solid var(--gray-5)", + border_radius="12px", + background="var(--gray-1)", + overflow="hidden", + width="100%", + ) + + +def kv(label: str, value: Any) -> rx.Component: + return rx.hstack( + rx.badge(label), + rx.text(value, font_family="monospace", font_size="13px"), + spacing="3", + align="center", + ) + + +# §1 wiring — the live figure var and its semantic events +def cloud_view() -> rx.Component: + return reflex_xy.chart( + Demo.cloud, + on_point_hover=Demo.on_hover, + on_point_click=Demo.on_click, + on_select_end=Demo.on_select, + height="460px", + id="cloud", + ) + + +# §2 wiring — a chart driven by a slider and another chart's selection +def histogram_view() -> rx.Component: + return rx.vstack( + reflex_xy.chart(Demo.histogram, height="240px", id="hist"), + rx.hstack( + rx.text("bins", size="2", color_scheme="gray"), + rx.slider( + default_value=[60], min=20, max=160, step=10, on_change=Demo.set_bins, id="bins" + ), + rx.text(Demo.bins, font_family="monospace", size="2", width="2.5rem"), + width="100%", + align="center", + spacing="3", + ), + rx.text(Demo.select_note, size="2", color_scheme="gray"), + width="100%", + spacing="2", + ) + + +# §3 wiring — a chart that grows from a background task +def live_view() -> rx.Component: + return rx.vstack( + reflex_xy.chart(Demo.live, height="240px", id="live"), + rx.button( + rx.cond(Demo.streaming, "stop stream", "go live"), + on_click=Demo.stream, + id="stream-btn", + ), + width="100%", + spacing="2", + ) + + +# §4 wiring — a detail chart computed from the overview's view-change events +def viewport_view() -> rx.Component: + return rx.grid( + reflex_xy.chart(Demo.overview, on_view_change=Demo.on_view, height="240px", id="overview"), + reflex_xy.chart(Demo.detail, height="240px", id="detail"), + columns="2", + gap="1rem", + width="100%", + ) + + +# §5 wiring — the two fixed-data tiers +def fixed_view() -> rx.Component: + return rx.grid( + reflex_xy.chart(sparkline_chart(), height="240px", id="inline"), + reflex_xy.chart(ORBITS_TOKEN, height="240px", id="orbits"), + columns="2", + gap="1rem", + width="100%", + ) + + +# §6 wiring — the whole drilldown integration is this one line +def drilldown_view() -> rx.Component: + return reflex_xy.chart(DRILLDOWN_TOKEN, height="430px", id="drilldown") + + +# §7 wiring — legend interactivity ships with the charts; no handlers needed +def legend_view() -> rx.Component: + return rx.grid( + reflex_xy.chart(legend_series_chart(), height="300px", id="legend-series"), + reflex_xy.chart(LEGEND_CATS_TOKEN, height="300px", id="legend-cats"), + columns="2", + gap="1rem", + width="100%", + ) + + +def index() -> rx.Component: + return rx.container( + rx.vstack( + rx.heading("xy × reflex", size="8"), + rx.text( + "Chart data rides the app websocket as binary buffers, with " + "kernel-side drilldown. Each section shows its own source below.", + color_scheme="gray", + size="3", + ), + section( + "1 · Live figure var + events", + "A 1M-point drillable scatter from an @reflex_xy.figure method. " + "Zoom to drill density into exact points; hover, click, and box-select. " + "Click and select handlers republish the chart itself (the title's " + "revision) — viewport and selection must survive each republish.", + rx.vstack( + cloud_view(), + kv( + "hover", + rx.cond( + Demo.hovered.length() > 0, + f"x={Demo.hovered['x']} y={Demo.hovered['y']}", + "zoom in to drill, then hover a point", + ), + ), + kv( + "click", + rx.cond( + Demo.clicked.length() > 0, + f"row {Demo.clicked['row']} · x={Demo.clicked['x']} " + f"y={Demo.clicked['y']} · modifiers={Demo.clicked['modifiers']}", + "zoom in to drill, then click a point", + ), + ), + kv( + "events", + f"{Demo.click_events} clicks · {Demo.select_events} selections · " + f"republish revision {Demo.interaction_revision}", + ), + width="100%", + spacing="3", + ), + code_accordion( + Demo.cloud, Demo.on_hover, Demo.on_click, Demo.on_select, cloud_view + ), + ), + section( + "2 · A chart driven by state vars", + "The histogram's bin count is a slider var, and its data is " + "cross-filtered by the box-selection above. Changing either " + "re-publishes the figure under a stable token.", + histogram_view(), + code_accordion(Demo.histogram, Demo.set_bins, histogram_view), + ), + section( + "3 · A dynamically updating chart", + "A line grown by a background task via reflex_xy.append; points " + "are pushed to subscribers as they arrive.", + live_view(), + code_accordion(Demo.live, Demo.stream, live_view), + ), + section( + "4 · Data computed from on_view_change", + "Zoom the overview's x range; the detail histogram recomputes from " + "only the points in view, driven by the view-change event.", + rx.vstack( + viewport_view(), + kv( + "view", + rx.cond( + Demo.view_ready, + f"x ∈ [{Demo.view_x0}, {Demo.view_x1}] · {Demo.visible} points", + "pan or zoom the overview", + ), + ), + width="100%", + spacing="3", + ), + code_accordion(Demo.overview, Demo.detail, Demo.on_view, viewport_view), + ), + section( + "5 · Fixed data, two ways", + "Left: a xy.Chart passed straight to reflex_xy.chart, compiled to " + "a static payload asset. Right: a reflex_xy.inline token, whose " + "fixed data answers hover/pick from the kernel.", + fixed_view(), + code_accordion(sparkline_chart, orbits_chart, fixed_view), + ), + section( + f"6 · The {_point_label(DRILLDOWN_POINTS)} drilldown, adapter-native", + "The live drilldown scatter from examples/fastapi — same data, " + "same mark config — with the adapter replacing that app's custom " + "HTTP transport (Starlette endpoint plus comm bridge). Zoom until " + "the density surface drills into exact points; XY_LIVE_POINTS " + "resizes both apps for side-by-side comparison.", + drilldown_view(), + code_accordion(drilldown_chart, drilldown_view), + ), + section( + "7 · Legend: hover to emphasize, click to toggle", + "Hover a legend row to dim every other series; click it to " + "hide/show what it stands for. Left: named series — a pure " + "client-side hide (0 wire bytes). Right: a categorical density " + "scatter served by the kernel — a category click sends " + "legend_toggle over the app websocket and the surface is " + "re-binned with the category masked out (§34). Both default " + "on: xy.legend(highlight=False) / xy.legend(toggle=False) " + "opt out.", + legend_view(), + code_accordion(legend_series_chart, legend_category_chart, legend_view), + ), + spacing="5", + width="100%", + ), + size="4", + padding_y="28px", + ) + + +app = rx.App() +app.add_page(index, title="XY Reflex showcase") diff --git a/tests/test_example_apps.py b/tests/test_example_apps.py index 576b4967..df1b7635 100644 --- a/tests/test_example_apps.py +++ b/tests/test_example_apps.py @@ -7,7 +7,6 @@ from __future__ import annotations -import importlib import importlib.util import inspect import os @@ -15,19 +14,13 @@ import sys from pathlib import Path -import numpy as np import pytest ROOT = Path(__file__).resolve().parents[1] EXAMPLES = ROOT / "examples" FASTAPI_DIR = EXAMPLES / "fastapi" REFLEX_DIR = EXAMPLES / "reflex" -REFLEX_PACKAGE = REFLEX_DIR / "xy_reflex_demo" -REFLEX_APP = REFLEX_PACKAGE / "xy_reflex_demo.py" -REFLEX_DATA = REFLEX_PACKAGE / "data.py" -REFLEX_CHARTS = REFLEX_PACKAGE / "charts.py" -REFLEX_STATE = REFLEX_PACKAGE / "state.py" -REFLEX_COMPONENTS = REFLEX_PACKAGE / "components.py" +REFLEX_APP = REFLEX_DIR / "xy_reflex_demo" / "xy_reflex_demo.py" def _load(path: Path, name: str): @@ -146,246 +139,37 @@ def test_fastapi_app_serves_live_charts_and_code() -> None: assert frame.buffers # the density grid rides raw, not base64 in JSON -# --- Reflex terminal data and chart builders (numpy + xy only) ------------- +# --- Reflex app structure (source text, no reflex import) ------------------- -@pytest.fixture(scope="module") -def terminal_data_mod(): - sys.path.insert(0, str(REFLEX_DIR)) - return importlib.import_module("xy_reflex_demo.data") - - -@pytest.fixture(scope="module") -def terminal_charts_mod(): - sys.path.insert(0, str(REFLEX_DIR)) - return importlib.import_module("xy_reflex_demo.charts") - - -def _chart_payload(chart): - source = chart if hasattr(chart, "build_payload") else chart.figure() - return source.build_payload() - - -def test_terminal_market_data_is_deterministic_and_valid(terminal_data_mod) -> None: - symbols = terminal_data_mod.instrument_symbols() - assert len(symbols) >= 8 - assert len(symbols) == len(set(symbols)) - assert terminal_data_mod.SIMULATED_DATA_LABEL.startswith("SIMULATED DATA · AS OF ") - - first = terminal_data_mod.history("AAPL", resolution="1D", range_key="MAX") - second = terminal_data_mod.history("AAPL", resolution="1D", range_key="MAX") - assert 700 <= len(first.x) <= 800 # three years of weekday observations - for field in ("x", "open", "high", "low", "close", "volume"): - np.testing.assert_array_equal(getattr(first, field), getattr(second, field)) - assert np.isfinite(getattr(first, field)).all() - - assert np.all(first.high >= np.maximum(first.open, first.close)) - assert np.all(first.low <= np.minimum(first.open, first.close)) - assert np.all(first.high >= first.low) - assert np.all(first.volume >= 0) - assert str(first.x[-1]) == terminal_data_mod.AS_OF.isoformat() - - weekly = terminal_data_mod.history("AAPL", resolution="1W", range_key="1Y") - assert 45 <= len(weekly.x) <= 54 - assert np.all(weekly.high >= np.maximum(weekly.open, weekly.close)) - assert np.all(weekly.low <= np.minimum(weekly.open, weekly.close)) - - -def test_terminal_reference_data_and_risk_are_reproducible(terminal_data_mod) -> None: - assert terminal_data_mod.positions() == terminal_data_mod.positions() - assert terminal_data_mod.stories() == terminal_data_mod.stories() - assert terminal_data_mod.calendar_events() == terminal_data_mod.calendar_events() - assert terminal_data_mod.portfolio_summary() == terminal_data_mod.portfolio_summary() - - equity = terminal_data_mod.portfolio_equity("1Y") - returns = terminal_data_mod.portfolio_returns("1Y") - assert len(equity.dates) == len(equity.equity) == len(equity.pnl) - assert len(returns) == len(equity.equity) - 1 - assert np.isfinite(equity.equity).all() - assert np.isfinite(equity.pnl).all() - assert np.isfinite(returns).all() - drawdown = equity.equity / np.maximum.accumulate(equity.equity) - 1.0 - assert np.all(drawdown <= 0) - - symbols, correlation = terminal_data_mod.correlation_matrix() - assert correlation.shape == (len(symbols), len(symbols)) - np.testing.assert_allclose(correlation, correlation.T) - np.testing.assert_allclose(np.diag(correlation), 1.0) - assert np.isfinite(correlation).all() - - scenarios_95 = terminal_data_mod.stress_scenarios(95) - assert scenarios_95 == terminal_data_mod.stress_scenarios(95) - assert scenarios_95 != terminal_data_mod.stress_scenarios(99) - assert all(np.isfinite(scenario.pnl) for scenario in scenarios_95) - - -def test_terminal_security_chart_exercises_finance_layers(terminal_charts_mod) -> None: - chart = terminal_charts_mod.security_chart( - "AAPL", - range_key="1Y", - resolution="1D", - overlays=( - "SMA 20", - "EMA 50", - "Bollinger bands", - "VWAP", - "Anchored VWAP", - "Volume profile", - ), - oscillator="RSI", - drawing="XABCD", - ) - spec, _ = chart.build_payload() - assert spec["traces"][0]["kind"] == "candlestick" - assert {trace["name"] for trace in spec["traces"][1:]} >= { - "SMA 20", - "EMA 50", - "VWAP", - } - layer_kinds = {layer["kind"] for layer in spec["layers"]} - assert {"bollinger_bands", "anchored_vwap", "anchored_volume_profile", "rsi"} <= layer_kinds - assert "xabcd_pattern" in layer_kinds - - position_chart = terminal_charts_mod.security_chart( - "AAPL", range_key="6M", drawing="Long position" - ) - position_spec, _ = position_chart.build_payload() - position = next(layer for layer in position_spec["layers"] if layer["kind"] == "position") - assert position["anchors"]["entry"]["x"] < position["anchors"]["end"]["x"] - - invalid_position_chart = terminal_charts_mod.security_chart( - "AAPL", - range_key="6M", - drawing="Long position", - ticket={ - "side": "Long", - "entry": 100.0, - "stop": 105.0, - "target": 115.0, - "account_size": 100_000.0, - "risk_percent": 1.0, - }, - ) - invalid_position_spec, _ = invalid_position_chart.build_payload() - assert "position" not in {layer["kind"] for layer in invalid_position_spec["layers"]} - - -def test_terminal_landing_market_focus_uses_finance_chart(terminal_charts_mod) -> None: - chart = terminal_charts_mod.market_focus_chart() - spec, _ = chart.build_payload() - - assert spec["traces"][0]["kind"] == "candlestick" - assert spec["tools"]["active"] == "forecast" - layer_kinds = {layer["kind"] for layer in spec["layers"]} - assert { - "volume_bars", - "moving_average", - "anchored_vwap", - "anchored_volume_profile", - "macd", - "position_forecast", - } <= layer_kinds - - -def test_terminal_ticket_metrics_validate_long_and_short(terminal_charts_mod) -> None: - long_metrics = terminal_charts_mod.ticket_metrics( - { - "side": "Long", - "entry": 100.0, - "stop": 95.0, - "target": 115.0, - "account_size": 100_000.0, - "risk_percent": 1.0, - } - ) - assert long_metrics["valid"] is True - assert long_metrics["risk_amount"] == pytest.approx(1_000.0) - assert long_metrics["quantity"] == pytest.approx(200.0) - assert long_metrics["risk_reward"] == pytest.approx(3.0) - - invalid = terminal_charts_mod.ticket_metrics( - { - "side": "Short", - "entry": 100.0, - "stop": 95.0, - "target": 80.0, - "account_size": 100_000.0, - "risk_percent": 1.0, - } - ) - assert invalid["valid"] is False - assert invalid["error"] - - -def test_terminal_workspace_chart_builders_emit_specs(terminal_charts_mod) -> None: - charts = ( - terminal_charts_mod.market_focus_chart(), - terminal_charts_mod.market_heatmap_chart(), - terminal_charts_mod.yield_curve_chart(), - terminal_charts_mod.market_pulse_chart(), - terminal_charts_mod.portfolio_performance_chart(), - terminal_charts_mod.portfolio_allocation_chart(), - terminal_charts_mod.portfolio_contribution_chart(), - terminal_charts_mod.portfolio_exposure_chart(), - terminal_charts_mod.risk_distribution_chart(95), - terminal_charts_mod.risk_correlation_chart(), - terminal_charts_mod.risk_factor_chart(), - ) - for chart in charts: - spec, _ = _chart_payload(chart) - assert spec["traces"] or spec.get("layers") - assert spec.get("title") - - assert terminal_charts_mod.MARKET_FOCUS_CHART is not None - assert terminal_charts_mod.MARKET_HEATMAP_CHART is not None - assert terminal_charts_mod.YIELD_CURVE_CHART is not None - abbreviated = terminal_charts_mod.abbreviated_spec() - assert abbreviated["traces"][0]["kind"] == "candlestick" - assert abbreviated["layer_count"] >= 3 - - -# --- Reflex terminal structure (source text, no reflex import) ------------- - - -def _terminal_source() -> str: - return "\n".join( - path.read_text(encoding="utf-8") - for path in (REFLEX_APP, REFLEX_STATE, REFLEX_COMPONENTS, REFLEX_CHARTS) - ) - - -def test_reflex_terminal_preserves_every_linking_tier_and_event() -> None: - src = _terminal_source() +def test_reflex_app_shows_every_linking_method_and_event() -> None: + src = REFLEX_APP.read_text(encoding="utf-8") required = [ - "class TerminalState", - "@reflex_xy.figure", - "reflex_xy.chart(", - "reflex_xy.append(", - "reflex_xy.inline(", - "MARKET_FOCUS_CHART", - "MARKET_HEATMAP_CHART", - "on_hover=", + "@reflex_xy.figure", # live figure var + "reflex_xy.chart(", # the component + "reflex_xy.append(", # streaming + "reflex_xy.inline(", # inline() token tier + "sparkline_chart()", # static Chart tier passed directly + # the FastAPI 100M drilldown, served adapter-natively (§6); both apps + # honor the same point-count override for side-by-side comparison. + "def drilldown_chart", + "reflex_xy.inline(drilldown_chart())", + "XY_LIVE_POINTS", + "on_point_hover=", + "on_point_click=", + "on_select_end=", "on_view_change=", - "inspect.getsource", - "@rx.event(background=True)", - "async with self", + # click/hover are off by default, so on_point_click needs them enabled. + "interaction_config(hover=True, click=True)", + "inspect.getsource", # introspected code accordions + "def code_accordion", ] for marker in required: assert marker in src, marker - assert src.count("@rx.event(background=True)") == 1 - assert src.count("async def stream_quotes") == 1 - assert "_stream_generation" in src - assert "generation != self._stream_generation" in src - - for workspace in ("Markets", "Security", "Portfolio", "Risk", "News"): - assert workspace in src - for command in ("MKTS", "DES", "PORT", "RISK", "NEWS", "HELP"): - assert command in src - - # All linking tiers stay native to the adapter; no iframe/message bridge. + # The showcase links charts natively, without iframe or postMessage bridges. assert "postMessage" not in src - assert "rx.el.iframe" not in src - assert " None: @@ -394,30 +178,27 @@ def test_reflex_config_wires_the_xy_plugin() -> None: assert 'app_name="xy_reflex_demo"' in cfg -def test_reflex_config_declares_dark_theme() -> None: - config_source = (REFLEX_DIR / "rxconfig.py").read_text(encoding="utf-8") - assert 'color_mode="dark"' in config_source - - -def test_reflex_terminal_imports_and_composes_in_temporary_cwd(tmp_path, monkeypatch) -> None: +def test_reflex_app_introspection_and_composition(tmp_path, monkeypatch) -> None: pytest.importorskip("reflex") pytest.importorskip("reflex_xy") - # Direct Chart payloads compile into cwd/assets/xy; keep generated files out - # of the repository and prove the example does not depend on its launch cwd. + # A static chart compiles a payload asset into cwd/assets/xy; keep it in tmp. monkeypatch.chdir(tmp_path) + # The §6 drilldown builds its columns at import; keep the test-time build + # cheap (same override the fastapi app test uses). + monkeypatch.setenv("XY_LIVE_POINTS", "50000") sys.path.insert(0, str(REFLEX_DIR)) - module = importlib.import_module("xy_reflex_demo.xy_reflex_demo") - state = importlib.import_module("xy_reflex_demo.state") - components = importlib.import_module("xy_reflex_demo.components") - + module = _load(REFLEX_APP, "xy_reflex_demo_under_test") + + # The Code accordion reads live source: figure vars unwrap to their builder, + # event handlers to their function — both include the decorator line. + assert "@reflex_xy.figure" in module._source(module.Demo.cloud) + assert "def cloud" in module._source(module.Demo.cloud) + assert "def on_view" in module._source(module.Demo.on_view) + # The page composes without error and mints inline() tokens at import. + assert module.ORBITS_TOKEN.startswith("xyin-") + assert module.DRILLDOWN_TOKEN.startswith("xyin-") + assert module.DRILLDOWN_POINTS == 50000 assert module.index() is not None - assert module.app is not None - assert state.TerminalState is not None - assert components.terminal_shell().class_name == "dark" - assert components.YIELD_CURVE_TOKEN.startswith("xyin-") - assert "@reflex_xy.figure" in components._source(state.TerminalState.security_figure) - assert "def stream_quotes" in components._source(state.TerminalState.stream_quotes) - assert (tmp_path / "assets" / "xy").is_dir() # --- retargeted browser smokes: import cleanly, pure helpers unit-tested ----- From 92e092e3b66bbffb2c0b779667c14fb54492a15f Mon Sep 17 00:00:00 2001 From: Alek Date: Tue, 4 Aug 2026 14:35:03 -0700 Subject: [PATCH 8/8] Keep finance panes within short charts --- js/src/50_chartview.ts | 24 ++++++++++++-- tests/test_ui_issue_regressions.py | 51 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index e1fb58f5..a46a0037 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -809,15 +809,33 @@ export class ChartView { if (!paneCount) return; const availableH = this.plot.h; - const gap = 10; + // The normal 10 px gaps and 36 px pane floor are preferences, not a + // license to extend past the authored plot rect. Reserve the hard 40 px + // main-plot floor first, then spend the remaining budget on pane content + // before whitespace; only shrink panes once every gap has collapsed. + const mainFloor = Math.min(40, availableH); + const paneFloor = 36; + const paneRegion = Math.max(0, availableH - mainFloor); + const gap = Math.min( + 10, + Math.max(0, Math.floor((paneRegion - paneFloor * paneCount) / paneCount)), + ); let paneH = Math.max( 44, Math.min(86, Math.floor((availableH * 0.42) / paneCount)), ); if (availableH - (paneH + gap) * paneCount < 90) { - paneH = Math.max(36, Math.floor((availableH - 90 - gap * paneCount) / paneCount)); + paneH = Math.max( + paneFloor, + Math.floor((availableH - 90 - gap * paneCount) / paneCount), + ); } - this.plot.h = Math.max(40, availableH - (paneH + gap) * paneCount); + const maxPaneH = Math.max( + 0, + Math.floor((availableH - mainFloor - gap * paneCount) / paneCount), + ); + paneH = Math.min(paneH, maxPaneH); + this.plot.h = availableH - (paneH + gap) * paneCount; let paneY = this.plot.y + this.plot.h; if (hasVolume) { paneY += gap; diff --git a/tests/test_ui_issue_regressions.py b/tests/test_ui_issue_regressions.py index c1080b55..7a8668a2 100644 --- a/tests/test_ui_issue_regressions.py +++ b/tests/test_ui_issue_regressions.py @@ -476,6 +476,57 @@ def test_modebar_active_button_uses_dark_active_color(tmp_path: Path) -> None: assert "rgb(0, 255, 0)" in result["customFocusShadow"], result +def test_short_finance_chart_keeps_all_lower_panes_inside_plot(tmp_path: Path) -> None: + x = list(range(32)) + close = [100.0 + index * 0.25 for index in x] + chart = xy.finance_chart( + xy.candlestick( + x, + [value - 0.1 for value in close], + [value + 0.4 for value in close], + [value - 0.4 for value in close], + close, + volume=[1_000.0 + index * 10 for index in x], + id="price", + ), + xy.volume_bars(source="price", pane="volume"), + xy.rsi(source="price", window=3, pane="rsi"), + xy.macd(source="price", fast=2, slow=3, signal=2, pane="macd"), + width=500, + height=320, + ) + script = ( + _PRELUDE + + """ + const availableH = 150; + const top = view.plot.y; + view.plot.h = availableH; + view._layoutFinancePanes(); + const panes = [view.volumePane, ...view.oscillatorPanes].filter(Boolean); + const paneBottom = Math.max(...panes.map((pane) => pane.y + pane.h)); + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({ + paneCount: panes.length, + mainPlotHeight: view.plot.h, + paneHeights: panes.map((pane) => pane.h), + panesOrdered: panes.every( + (pane, index) => index === 0 || + pane.y >= panes[index - 1].y + panes[index - 1].h + ), + allocatedHeight: paneBottom - top, + availableH, + })); +""" + + _POSTLUDE + ) + result = _probe(chart, script, tmp_path, "short finance pane layout") + + assert result["paneCount"] == 3, result + assert result["mainPlotHeight"] >= 40, result + assert all(height >= 36 for height in result["paneHeights"]), result + assert result["panesOrdered"] is True, result + assert result["allocatedHeight"] <= result["availableH"], result + + def test_narrow_annotation_labels_stay_inside_and_do_not_collide(tmp_path: Path) -> None: chart = xy.line_chart( xy.line([0, 25, 50, 75, 100], [0.1, 0.3, 0.55, 0.8, 1.0]),