diff --git a/docs/quant-finance-roadmap.md b/docs/quant-finance-roadmap.md new file mode 100644 index 00000000..c4fd9864 --- /dev/null +++ b/docs/quant-finance-roadmap.md @@ -0,0 +1,485 @@ +# 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. 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..a46a0037 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,79 @@ 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; + // 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( + paneFloor, + Math.floor((availableH - 90 - gap * paneCount) / 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; + 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 +3899,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 +4949,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 +6467,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 +6756,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 +7862,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 +8141,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 +8166,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 +8574,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 +8604,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..90568dad --- /dev/null +++ b/js/src/57_layers.ts @@ -0,0 +1,892 @@ +// --------------------------------------------------------------------------- +// 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) { + if (v === null || v === undefined || v === "") return NaN; + 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 = layerNumber(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 = layerNumber(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 = 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 = 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); + 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 = layerNumber(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/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/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..5650a9b9 --- /dev/null +++ b/python/xy/finance.py @@ -0,0 +1,2542 @@ +"""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, (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)): + 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..4e81007e 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -284,6 +284,31 @@ 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. + +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/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/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_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..dfdb1b07 --- /dev/null +++ b/tests/test_finance.py @@ -0,0 +1,787 @@ +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 _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) + 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( + _float_values(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( + _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(): + 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_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)), + 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_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") 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", diff --git a/tests/test_ui_issue_regressions.py b/tests/test_ui_issue_regressions.py index 22b58d06..7a8668a2 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,12 +468,65 @@ 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 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]),