Repository navigation
0.2.0 - 2026-10-11
Added β Documentation media
Real Simulator GIFs and screenshots (demo tour, replay, comparison, session shading, Chart Options, Layouts, share sheet, home-screen widget, Live Activity) in docs-assets/media/, shown in the README and in new website guides for comparison and sessions, replay, and widgets and Live Activities. The App Intents flow in Shortcuts has no capture yet.
Changed β Demo: comparison, session shading and replay in Chart Options
The Market screen's Chart Options sheet now has Compare (up to three symbols of the same kind, percent or indexed-100), Sessions (shade extended hours) and Replay (play, pause, step and speed controls above the chart). To make session shading meaningful the Demo gains simulated equities (AAPL, MSFT, SPY) that trade only 04:00β20:00 New York time on weekdays; equities are simulated only because the live feed is crypto. Simulated symbols now use a per-symbol seed so they move differently from each other. Comparison lines load once and do not tick with live trades. Written without a compiler; not yet built or run.
Added β Live Activity sparkline and watchOS support for the widget product (roadmap 8.2, 8.3)
SparklineView in CandleKitWidgets and SparklineSampling in CandleKitCore (with tests). CandleKitCore and CandleKitWidgets now declare watchOS 10, and CI builds CandleKitWidgets for the watchOS Simulator. The Demo gets a Live Activity (PriceLiveActivity, a Styles-tab card to start and end it) sharing PriceActivityAttributes between the app and the widget extension. docs/design/visionos.md records the 8.6 questions; no visionOS code yet. Written without a compiler; not yet built or run.
Added β App Intents sample and guide (roadmap 8.4)
No new library API. The Demo gets ShowChartIntent (asset and timeframe parameters), an AppShortcutsProvider and a DemoRouter that makes the Market tab switch product on warm and cold launch; docs/guides/app-intents.md describes the pattern. Not yet run from Shortcuts, Siri or Spotlight.
Added β Home-screen widget chart (roadmap 8.1)
New CandleKitWidgets product with WidgetCandleChart (candles or line, Shape-based, no gestures) and CandleAggregator.downsample(_:maxCount:) in CandleKitCore to bound draw cost. The Demo gains a CandleKitDemoWidget extension target (small/medium/large, simulated BTC data), declared in Demo/project.yml; run xcodegen generate to pick it up. Design notes in docs/design/widget.md. Not built or run yet: no Swift toolchain was available where this was written.
Added β Replay mode (roadmap 9.4)
ReplayController (@MainActor @Observable) and CandlestickChart.replay(_:) play a loaded series back candle by candle: begin(at:), play(), pause(), step(_:), end(), adjustable candlesPerSecond, auto-stop at the last candle. The pure timing code (ReplayClock, ReplayMath) lives in CandleKitCore with tests. Deviation from the roadmap sketch: not a CandleChartState method, since the state doesn't own the data. The Demo's Styles tab has a Replay card. Exercised in the iOS Simulator; the new Core tests have not been run (no Swift toolchain where they were written).
Added β Share chart as image (roadmap 8.5)
CandleChartState.snapshot(scale:colorScheme:watermark:) returns a UIImage of the chart as it
currently looks on screen, for sharing or saving. It redraws the state's last ChartFrame through
ImageRenderer, using the same ChartBaseLayer Canvas as the live chart, so the image can't drift
from the screen. PriceAxisGlassPanel's tick-label loop moved into a shared PriceAxisLabels view
for the same reason, and CandleChartState now remembers the CandleChartStyle its current frame
was built with (currentStyle, @ObservationIgnored). ChartWatermark adds an opt-in corner label
(.candleKit or your own text); there is no watermark by default. Not in the image yet: crosshair,
drawings and the header; a frosted axis is drawn plain. The Demo's Market screen gets a share button.
Written without a compiler or a device: the iOS build and the checks listed under 8.5 in
docs/ROADMAP.md still need to be run.
Fixed β Found by running the Demo in the iOS Simulator (first compile and run of 8.5, 9.1, 7.6)
- Comparison: the 0% axis tick read "-0.0%" (
niceTicksproduces -0.0); labels are now rounded to the
digits shown first. - Comparison: the legend sat above the chart and so read
currentFramebeforemakeFramehad run
for that update; it now sits inside the chart afterChartContentLayer, in the top-left corner. CandleChartState.isScrollingnow bumpsrevisionwhen it clears, so the frame rebuilt against the
settled viewport (new price range, new comparison base) is built promptly, and the first settled
frame schedules one more deferred bump so views that readcurrentFrameearlier in the same update
(the price-tag badge, the legend) re-read it. Affects every chart, not only comparison.
Added β Multi-symbol comparison (roadmap 7.6)
.comparing(_:normalization:) plots other instruments over the chart. ComparisonSeries carries a
name, candles, optional color and line width. The price axis becomes percent change from the first
visible candle (or an index starting at 100), the crosshair and last-price tags follow, and a legend
in the chart's top-left corner shows each line's value. CandleKitCore gains SeriesAlignment.align (timestamp
alignment with explicit gaps), ComparisonMath and ComparisonScaling; PriceScale.autoRange gains
an extent: parameter and PriceTicks a public initializer. Each comparison line is its closes
times one constant, so the candle renderer is unchanged. Design in docs/design/comparison.md.
Written without a compiler or a device: the Core tests and the iOS build still need to be run (see
7.6 in docs/ROADMAP.md). The header, accessibility summary and drawings keep the chart's own prices.
Added β Extended-hours / session shading (roadmap 9.1)
.tradingSessions(_:shading:) tints the plot behind pre-market, after-hours and overnight candles.
TradingSession (kind, start/end minute, weekdays, time zone; TradingSession.usEquities as a
preset) and SessionClassifier live in CandleKitCore and use integer epoch math with a cached
UTC offset that is re-read only when a daylight-saving transition passes. Regions are computed once
per data change and cached on CandleChartState; the renderer binary-searches them per frame.
SessionShadingStyle sets the colors. Sessions apply to intervals of one hour or shorter. The
Demo's Styles tab gets a "Session shading" card. Written without a compiler or a device: the Core
tests and the iOS build still need to be run (see 9.1 in docs/ROADMAP.md).
Docs β Repository hygiene: real build commands in CONTRIBUTING.md, formatting decision
CONTRIBUTING.md had generic, Xcode-GUI-only build instructions. It now has a "Building and
testing" section with the actual commands: swift build/swift test for CandleKitCore, the
xcodebuild -scheme CandleKit invocation that's the only way to compile the SwiftUI layer (plain
swift build doesn't, since everything under Sources/CandleKit/ is #if os(iOS)), and the demo
app's xcodegen generate β xcodebuild workflow. Also added a "Formatting" section: the project
will use swift-format (ships with the Swift 6 toolchain), but a checked-in config and CI lint
step are deferred to a separate PR rather than bundled with this change. No code changes; main
already had CHANGELOG.md in Keep a Changelog format, so that part of the task was already done.
Changed β Demo app: Market screen redesign (named layouts, modern options sheet, floating controls)
Direct feedback across a few rounds: the Market screen's chrome had grown busy (a segmented
timeframe picker, drawing toolbar, and price-alert row each permanently stacked above the chart,
plus a footer), the chart-options Menu read as "lame and old school," and there was nowhere to
keep more than one saved layout at a time. This is a from-scratch pass on all of it β library code
is untouched; everything here is Demo/CandleKitDemo/Market/MarketView.swift and
DemoLayoutStorage.swift.
- Named, multi-slot layouts, replacing the original single-file Save/Load pair.
SavedLayout(Identifiable, Codable, Equatable:id,name,savedAt,layout: ChartLayout) is stored
as a JSON array by a rewrittenDemoLayoutStorage(loadAll/add/overwrite/rename/delete),
one file in the app's Documents directory. A newLayoutsSheetlists every saved layout newest
first, with load-on-tap, swipe-to-delete, swipe/context-menu-to-rename, "update this one with the
current chart" (re-capture into an existing entry instead of piling up near-duplicates), and a
"+" to save the current chart as a new entry. - The chart-options
Menuis gone, replaced byChartOptionsSheetβ a grouped, scrollable
sheet: a "Layouts" card (opensLayoutsSheet), a "Market" card for data source/symbol, and
icon-tile chips for each overlay/pane (SMA, EMA, Bollinger, VWAP, Volume, RSI, MACD) that light up
with the accent gradient when active, plus a Reset Zoom button. Opening Layouts from inside it
dismisses this sheet and presentsLayoutsSheetafter a short delay, rather than presenting both
at once (which races their two.sheettransitions against each other). - The screen is no longer a stack of permanent rows. The timeframe picker is now
TimeframeStrip
β a floating pill row β and the drawing toolbar + a new compactPriceAlertControl(a bell that
expands in place into a price field, replacing the old always-open text row) share one floating
FloatingToolDockat the bottom. Both sit in an ordinaryVStackalongside the chart (not a
ZStackoverlay drawn on top of it β see Fixed, below, for why that distinction mattered), so the
chart gets real, substantially more vertical space than the original four-row layout. The footer
("Drag to scroll, pinch to zoomβ¦" and the data-source attribution line) is removed entirely. - Haptics and non-blocking feedback, applied consistently:
UISelectionFeedbackGeneratorfor
picking/toggling something,UIImpactFeedbackGeneratorfor a committing action, and the two
system notification feels for "this succeeded" / "this fired" (price alert). A transientToast
overlay replaces the old modal.alert(item:)for layout save/load/error feedback, so routine
confirmations no longer stop the trader mid-flow to dismiss them.
Fixed
- Volume bars (and the time axis) hidden behind the floating tool dock. The first cut of this
redesign put the chart full-bleed under aZStack, with the toolbar/dock drawn as an overlay on
top of guessed padding. The chart's actual frame never shrank to make room, so it kept drawing its
volume bars and time axis all the way to the screen edge, and the dock's own background simply
painted over them. Fixed by switching to a plainVStack(pill row β chart β dock) so the chart
is given its real, reduced height and nothing it draws can end up hidden behind chrome. - The toolbar's status badge could render as a bare dot, with "Live"/"Simulated" clipped off
entirely β the toolbar was free to compressFeedStatusBadgebelow its intrinsic width whenever
the center title and trailing button claimed the rest of the space. Pinned to its own ideal size
with.fixedSize(); the title truncates instead, which it already does gracefully. - The price-alert bell and the last drawing-tool icon could overlap in the floating dock on
narrower screens β the drawing-tool strip's.frame(maxWidth:)wasn't actually clipping its
ScrollViewcontent to that width. Switched to a fixed.frame(width:)with.clipped(), and
trimmed both the tool strip and the price field a bit further for headroom on smaller devices. - A stray white panel behind the nav bar title. Once the chart stopped running full-bleed under
the nav bar (the fix above), its own opaque background plate started reading as an unexplained
white panel behind "Bitcoin" and the floating timeframe strip beneath it.
.toolbarBackground(.hidden, for: .navigationBar)covers the classic plate; iOS 26's "Liquid
Glass" nav bar draws that same plate through a separate, newer, iOS 18+-only API instead
(toolbarBackgroundVisibility(_:for:)), so it's applied too, gated behind an availability check
since this project's deployment target is iOS 17.
Added β layout persistence, price-crossing primitive, manual price scaling (roadmap 7.3, 9.3, 4.3)
The gap between "cool demo" and "a tool someone actually tracks a position with across days": nothing
persisted before this β every drawing, indicator, and style choice lived only in @State and was
lost on relaunch. This closes that gap, plus the two other most-requested trader-facing primitives.
ChartLayout: Codable(7.3). NewCandleKitCore.ChartLayoutβ style, active indicators
(with colors, line width, visibility), drawings, and viewport, in one versionedCodablevalue.
Reuses the existingCodableDrawingandIndicatorDescriptorinfrastructure rather than
inventing new serialization.schemaVersionis checked on decode (ChartLayoutError .unsupportedSchemaVersionon a newer, unrecognized version);indicators/drawingsdefault to
[]when absent, so a payload from a future version that added a field still loads.Viewportis
nowCodable. A newCandleKit-layer file,ChartLayout+CandleKit.swift, bridges
CandleChartStyle/ChartIndicator(which useSwiftUI.Color) to and from the portable snapshot β
the same UI-boundary patternDrawingColor+SwiftUI.swiftalready established, which itself gained
the reverseDrawingColor.init(_ color: Color)conversion needed here.CandleChartStyle .priceAxisMaterialdeliberately isn't captured:Materialhas no public API to read an arbitrary
value back into a named case, so the save direction has no possible implementation β restoring a
layout always sets it back tonil, and an app using the glass axis re-applies that setting
itself.CandleChartStategainedcurrentViewportandrestoreViewport(_:)so a saved viewport
can be read back and re-applied, clamped to whatever data is currently loaded.- Price-crossing primitive (9.3). New
priceCrossings(in:levels:)(CandleKitCore /PriceCrossing.swift) β pure Foundation, no dependency onCandlestickChartor any chart state.
Answers "did the price cross this level," comparing closes between the two most recent candles;
works the same for a newly appended candle and a live tick updating the last candle in place.
Deliberately not an alerting system β no notification scheduling, no persisted watchlist β per the
roadmap's "what CandleKit will not become": an app wires its own notification and persistence to
this primitive, as the Demo app now does. - Manual price-axis scaling (4.3).
CandleChartState.PriceScaleMode(.automatic/
.manual(ClosedRange<Double>)),setManualPriceRange(_:),resetPriceScale(), and
currentPriceRange.makeFramenow skips autoscaling entirely while in manual mode β the range
stays put until reset, even as new candles arrive, matching every real trading app's behavior.
CandlestickChartgained aPriceAxisScaleGestureoverlay, scoped to just the price-axis column
(mirroring the existing glass-axis panel's positioning): drag to rescale around the range's center,
double-tap to return to autoscale. Plain SwiftUI gestures on a new, previously-untouched region β
not routed through the existing UIKit pan/pinch/long-press coordinator β so it can't affect
ScrollView-embedded charts or the existing gesture set. - Renamed an internal type to avoid a naming collision:
ChartFrame.swift's internal
screen-layout struct (pane/axis rects) is nowChartRegions, freeing up theChartLayoutname for
the new public persistence type above. Not part of the public API β nothing outside this package
could have referenced the old internal name. - Demo app: a "Layout" section in the chart-options menu (Save Layout / Load Layout), a price
alert row (enter a level, get a banner when it's crossed), and the chart's style is now app-owned
@Stateinstead of a constant, so there's something for Save/Load to actually round-trip. Dragging
the price axis works with no Demo-specific code, once the library change landed.
Verification
No Swift toolchain is available in the environment this was written in (swift/swiftc not found,
and download.swift.org was blocked), so none of this was compiled or run β not even the
Linux-buildable CandleKitCore parts, which would ordinarily be the one thing checkable from the
command line. Reviewed carefully by hand against the existing code's own patterns. Before relying on
this, at minimum: swift build && swift test (covers ChartLayout, PriceCrossing, and Viewport : Codable, including their new test files); an iOS build (Sources/CandleKit and the Demo app are
untouched by any Linux-side verification); Save β relaunch β Load actually restores drawings,
indicators, and style; dragging the price axis rescales and double-tapping resets, without breaking
panning/pinching on the chart itself or scrolling on a ScrollView-embedded chart tab; and a price
alert fires on a live tick crossing the entered level.
Added β drawing tools trader-UX pass (color, live price/time readout, haptics)
Direct feedback: drawing tools worked, but didn't yet feel like something a trader would reach for β
no way to tell what price a line sits at, no color choice, and single-anchor tools committed on a
plain tap with no chance to nudge them into place first. This pass is aimed squarely at that gap.
- Drag-to-position for single-anchor tools.
DrawingController.beginPrimaryGestureno longer
requiresanchorCount > 1to start a live preview β a horizontal line, vertical line, or text
note can now be pressed and dragged into place, with the same live preview multi-anchor tools
already had, instead of only committing wherever a tap happened to land. A quick, no-drag tap
(handleTap) still works exactly as before for a fast placement. - Live and permanent price/time axis tags. Every horizontal line/ray shows its price on the price
axis, and every vertical line shows its time on the time axis β both while being dragged into place
and once committed β reusing the sameChartText.drawPriceTag/drawTimeTagprimitives the chart's
own last-price badge already draws with, so the styling matches. This is the answer to "the user
should... know the price in which the horizontal line is drawn." - Live measurement badge for two-anchor tools. While a trend line, ray, rectangle, or Fibonacci
retracement is being dragged, a floating badge near the live anchor shows the signed price delta and
percent change from the first anchor, colored green/red by direction β gone once the drawing is
committed, so it doesn't clutter the chart permanently. - Per-drawing color. New
CandlestickChart.defaultDrawingStyle(_:)modifier sets what a newly
created drawing'sstylestarts as (color, line width, dash, fill opacity);Drawing.stylewas
already per-drawing (not chart-wide), so recoloring an existing drawing β including the selected
one β is just mutatingstyle.coloron the app's own.drawings(...)array, the same "app owns the
array" pattern already used for deletion.Color(_ drawingColor: DrawingColor)is nowpublicso an
app's own color-swatch UI can render a swatch that matches exactly. - Haptics while drawing.
ChartGestureCoordinatornow fires a light tap when a drawing gesture
begins (an active tool, not a selection drag), a medium tap when it commits, and a medium tap on a
successful quick-tap placement β reusing the existingimpactLight/impactMediumgenerators, no
new haptic feedback objects. The existing light tap on a new snap target is unchanged. This directly
answers "use haptics when user is drawing the horizontal lines," generalized to every tool rather
than special-cased to just one. - Demo app:
DrawingToolPickergained a row of color swatches beneath the tool row. Tapping one
sets the color the next drawing will use and, when a drawing is already selected, recolors it
immediately β the same tap does the obvious thing in either context.
Verification
Reviewed carefully by hand; not compiled or run on a device, same caveat as every drawing-tools change
so far in this project. The parts most worth a real-device pass: whether dragging a single-anchor
tool actually reads as "drag to position" rather than "the tap moved unexpectedly" (a UX judgment call
that needs a finger on glass, not just code review); whether the axis tags' positions stay legible
when multiple horizontal lines sit close together in price (they'll currently stack without avoiding
each other); and the haptic timing/weight (light-vs-medium, begin-vs-commit) β haptics are notoriously
hard to judge right from source alone.
Added β Tier 2 indicator catalog (roadmap 5.6/5.7)
Twelve new indicators, closing out the Tier 2 catalog the roadmap laid out (Volume Profile
deliberately excluded β see below):
- Trend overlays: Donchian Channels, Keltner Channels, SuperTrend, Parabolic SAR, Ichimoku
Cloud, Pivot Points (classic/Fibonacci/Camarilla). - Oscillators: ADX/DMI, Commodity Channel Index, Money Flow Index, Williams %R, Rate of
Change/Momentum (one indicator, a mode switch), Chaikin Money Flow. - All twelve are
Indicatorconformances over newIndicatorMathfunctions
(IndicatorMathTier2.swift), each documenting its convention the same way Tier 1 does. Reach them
throughIndicatorCatalog.full(standard + Tier 2) or the newChartIndicatorfactories
(.donchianChannels(),.superTrend(),.adx(), etc.). - The renderer's fill drawing gained real crossover-coloring support (
IndicatorFill.colorFollowsCrossover
existed in the model since 5.1 but was never actually implemented βBaseLayerRenderer.drawFills
always used one fixed color). Ichimoku's cloud needs this to flip between bullish and bearish
tinting as Span A crosses Span B, so this pass implements it: a fill run splits wherever the two
plots' raw values cross, each side colored independently. Every other fill (Bollinger, Donchian,
Keltner) is unaffected β they don't setcolorFollowsCrossover, so they take the same single-color
path as before. - Known gap, documented rather than silently accepted: Ichimoku's Senkou spans don't yet project
past the most recent candle into blank future space, the way a real Ichimoku cloud does β the
per-candle array model has no representation for a position beyond the data. Extending a pane past
the last candle is a renderer-level change; tracked as roadmap follow-up. - Volume Profile deliberately not implemented. As the roadmap already flagged, it's a horizontal
histogram binned by price rather than a per-candle series, and doesn't fitIndicatorResult
without a new output case β that's its own design task, not something to force into this pass. - Cross-checked against an independently written Python reference implementation of each formula
(Tier2IndicatorMathTests.swift), same policy and same caveat as Tier 1: internally consistent and
formula-accurate, not yet validated against a live TradingView chart or another authoritative
source (roadmap 5.9, now explicitly covering Tier 2 too).
Added β drawing tools: design note, Core model, renderer, gestures (roadmap 6.1β6.3)
docs/design/drawing-tools.mdβ the design note the roadmap calls for before any drawing-tool
code: the app-ownedCodablemodel anchored to(Date, price), the.drawings()/.drawingTool()
API shape, how a drawing tool's drag coexists with the chart's existing pan/pinch/long-press
(a mode switch on the sameChartGestureCoordinator, not simultaneous gesture recognition), hit
testing tolerances, z-order/lock/visibility,UndoManager-based undo, and a VoiceOver plan.- Core model (
Sources/CandleKitCore/Drawings/):Drawing,DrawingAnchor,DrawingKind,
DrawingTool,DrawingStyle,DrawingColor(allCodable, no UI-framework dependency, same as
the indicator model), plusDrawingGeometryβ anchorβposition mapping against the chart's
index-based viewport (exact match, interpolation, edge extrapolation) and its inverse
(positionβanchor, for turning a live touch back into a(Date, price)), and
distance-to-segment/infinite-line/ray/rectangle-edge for hit testing. All unit-tested on Linux,
including a round-trip test (time(forPosition:)βposition(for:)β back) cross-checked against
an independent Python reference. - Renderer, controller, and gesture integration land on top of that model in this same pass:
DrawingsLayer/DrawingsLayerRendererβ draws every visible drawing, the tool-in-progress
preview, and the selected drawing's anchor handles.DrawingScreenMappingβ the one place anchors convert to and from screen pixels, shared by the
renderer and hit testing so they can't independently disagree about where a drawing sits (the
same roleCandleGeometryplays for candle bodies, and for the same reason: that exact class of
drift was a real, user-reported bug for the crosshair beforeCandleGeometryexisted).DrawingControllerβ active-tool creation state, selection, whole-drawing move, and snapping to
the nearest visible candle's OHLC within a configurable radius (roadmap 6.2), reusing the
existingimpactLighthaptic on each new snap.ChartGestureCoordinatorgains adrawingControllerproperty and a guarded branch in its pan
handler (claimed once, at gesture-began time, never renegotiated mid-drag), pinch and long-press
now refuse to begin while a tool is active, and a new single-tap recognizer (required to fail
against the existing double-tap) handles single-anchor placement and cursor-mode selection.
Every one of these is a no-op path when no.drawings(...)is attached, so an ordinary chart's
gesture behavior is provably unchanged.- New public API:
CandlestickChart.drawings(_:)/.drawingTool(_:)/.selectedDrawing(_:)
(the last one-way, chart β app, so an app can show its own inspector or delete button β deletion
itself stays a plaindrawings.removeAll { ... }on the app's own binding, per the note's Β§4/Β§5).
- Eight of Tier 1's nine tools (6.3) are implemented this way: horizontal line, horizontal ray,
vertical line, trend line, ray, rectangle, Fibonacci retracement, text note. The measure tool is
not β it's transient and non-persisted like the crosshair, not modeled inDrawingKind, and
needs its own small piece of state rather than fitting this pass'sDrawing-shaped machinery. - Known gaps, called out rather than left implicit (see the roadmap's 6.1 entry for the full
list): per-anchor resize (moving a selection today translates the whole drawing, not one endpoint);
no in-chart text entry for.textNoteyet (a tap places one with placeholder text); VoiceOver for
drawings β both inspecting an existing one and constructing one β is still only the plan in the
design note's Β§6, not code.
Verification
None of the above has been run in an iOS build or on a device β same caveat as every other change in
this project made without a Swift toolchain available to compile or execute it. Reviewed carefully
by hand and cross-checked with independent reference logic wherever the math allowed it (the
positionβanchor round-trip in particular), but not compiled, not run, and the gesture-mode switching
in ChartGestureCoordinator especially deserves a real device pass before shipping β it's the part
of this codebase with the most previously hard-won, easy-to-regress correctness.
Fixed β the toolbar's "Chart options" menu re-rendering on every live trade
Reported directly: the Demo app's menu "always gets refreshed." Root cause was MarketView.body
itself, not the menu: feed.candles (from MarketFeed, an @Observable that mutates on every
incoming trade β often several times a second for the simulated/live feed) was read directly inside
MarketView.body, both via a .animation(value: feed.candles.isEmpty) modifier and the chart
computed property. Since optionsMenu was also a plain computed property evaluated inside that
same body, every trade tick re-evaluated the whole body β including rebuilding the Menu's entire
content from scratch, even though nothing in the menu itself ever reads feed.
- Split
MarketView.bodyinto dedicatedViewtypes along the same fault line the file's own
JumpToLatestButtonalready establishes ("lives in its own view so that only this button, not the
whole screen, re-renders"): a newMarketChartSectionowns everything that readsfeed.candles,
and a newChartOptionsMenuowns the toolbar menu, taking onlyBindings and a plain
CandleChartStatereference β nofeeddependency at all. - With that split, a live trade re-renders only
MarketChartSection(which is supposed to update
live);MarketView.bodyand the toolbar'sMenuare no longer touched by it.
Verification
Not yet confirmed on a device β the fix follows an established, already-proven pattern in this same
file (JumpToLatestButton), and the reasoning (an @Observable property read inside a shared body
taints that whole body's Observation scope) is a well-documented SwiftUI behavior, not a guess, but
whether the menu now visibly stays put while trades stream in hasn't been eyeballed on a real device.
Fixed β crosshair dulling moved into the real candle draw call, fixing alignment drift
Follow-up to the previous round's fix (which stopped the crosshair from dimming the whole plot and confined it to candle shapes only): that version drew the dimmed shapes as a separate overlay, recomputing each candle's outline independently of BaseLayerRenderer's own pixel-snapped geometry. The two calculations agreed closely but not exactly, and the small, zoom-dependent gap between them was visible as the gray shapes drifting slightly out of alignment with the real candles underneath β worse at some zoom levels than others, and generally reading as a bug rather than an effect.
- No more second shape.
CrosshairLayerno longer builds or fills an "other candles" overlay at all. Dulling now happens insideBaseLayerRenderer.drawCandlesitself: candles are split into a normal-opacity batch and a reduced-opacity batch based on whether each one is the focused index, using the exact same per-candle rectangles either way. There is only ever one calculation of where a candle's edges are, so there's nothing left that could disagree with it. - New shared geometry helper.
CandleGeometry.compute(index:frame:style:pixels:)is the pixel-snapped body/wick rectangle calculation, factored out so bothBaseLayerRenderer(drawing every candle) andCrosshairLayer(tracing the focused candle's glow) derive their rectangles from the same rounded numbers. The glow's outline is built from this too now, instead of its own separate, unsnapped shape β fixing the same class of drift for the glow ring itself, not just the dimming. crosshairDimOpacityre-scoped, not renamed. It now controls how much a non-focused candle's own fill opacity is reduced (blending it toward whatever's behind it), rather than the alpha of a black shape drawn on top of it. Same property, same default (0.35), same 0...1 range β just applied directly to the real draw instead of a stand-in for it.
Verification
Not yet confirmed on a device. The geometry is now provably identical between the dimmed candles and the normally-drawn ones (same function, same inputs), which removes the specific bug reported, but the visual read of "opacity-reduced candle" vs. the earlier "gray tint on top of candle" hasn't been compared side by side on a screen yet β worth a look in both light and dark mode, particularly for hollow up-candles, whose stroke-only outline dims a little differently than a filled body.
Fixed β crosshair no longer dims the whole chart, only the other candles
Follow-up to direct device feedback (screenshots in both light and dark mode): hovering the crosshair was darkening the entire plot area β grid, background, volume bars, indicator lines, all of it β when only the non-focused candles should recede.
- Full-plot scrim removed. The previous implementation filled the whole visible plot (every pane) with a blurred black scrim and cut a candle-shaped hole out of it via an even-odd fill. That's what made the whole background go gray instead of just the candles.
- Replaced with a per-candle fill.
CrosshairLayer.drawFocusGlownow builds one combined path out of every other visible candle's own body-and-wick silhouette (candleSilhouette(_:frame:style:expand:), extracted so the focused candle's glow and the dimming fill share identical geometry) and fills just that path withstyle.crosshairDimOpacity-strength black, no blur. Background, grid, axes, volume bars, and indicator lines are untouched β only candle pixels can darken. - Focused candle's glow is unchanged (stroked outline, additive blend, two passes) β only what happens to the other candles changed.
- Cost stays bounded to the visible window. The dimming path is built once per crosshair move from
frame.visible(already the on-screen candle range), not the full dataset, and filled in a single draw call.
Verification
Not yet confirmed on a device. The geometry mirrors the existing per-candle body/wick shape used elsewhere (same rounded-rect construction, same bodyWidthRatio), so it should align pixel-for-pixel with the real candles underneath it, but that alignment β and whether the flat, unblurred edge reads as intentional rather than jagged at the candle boundary β needs eyes on a real screen in both light and dark mode before calling this settled.
Changed β price axis now blends toward the background instead of standing out
Follow-up to the previous round's structural fix (plain Material, three-edge feathering) β this one addresses colour and opacity specifically, per feedback that the panel's own tint still read as a distinct box against its surroundings.
- Blended toward the system background colour.
Materialcarries its own neutral grey-ish tint no matter what's behind it, which is what made it stand out rather than blend in. The panel now overlaysColor(uiColor: .systemBackground)at low opacity on top of the material β.systemBackgroundresolves correctly in both light and dark automatically, no explicit colour-scheme branch needed. - More translucent than any single
Materiallevel allows. There's no "thinner than ultraThin" material to reach for, so the whole panel now also gets a flat opacity reduction (0.7) on top of the background blend, for translucencyMaterialalone can't reach. - Feather fractions widened (leading 15%β20%, top/bottom 8%β12% each) for a smoother, less abrupt dissolve at all three edges.
Verification
This round trades legibility against translucency more directly than the previous ones did β the whole point of a backing panel is to keep tick labels readable against moving candle colours, and turning that panel more translucent works directly against that. Whether 0.7 opacity still gives labels enough of a backdrop, or needs pulling back up, is exactly the kind of judgement call that needs eyes on a real screen, in both light and dark mode, not just reasoning.
Earlier unreleased work
Fixed β glass axis based on real device screenshots
The previous two rounds were reasoned about carefully but never seen rendered. This round is driven directly by screenshots showing what was actually wrong.
glassEffectreverted. The screenshots showed visible colour-tinted glow artefacts along the top and bottom of the axis strip β greenish and blue-ish halos that clearly weren't a smooth material.glassEffectis a sophisticated, very new API, and diagnosing or tuning that kind of artefact without a device in hand isn't something that can be done responsibly by guessing at parameters a third time. The axis now uses a plainMaterialfill on every iOS version β predictable, well-understood, and known to adapt correctly to light and dark mode on its own. True Liquid Glass is future work, not abandoned, but it needs a way to iterate against a real device rather than another blind attempt.- Corner rounding and the stroke overlay removed. A rounded shape plus a stroked outline are both hard-edged geometric treatments; layering them under an alpha feather (below) meant two different kinds of "edge" competing for attention. A plain rectangle, feathered, is simpler and was more likely to be part of what was reading as a box.
- Feathering extended from one edge to three. The previous version only faded the leading edge; the screenshots specifically showed hard top and bottom edges too. Two
.mask(LinearGradient)calls now stack β leading (15%), then top/bottom (8% each) β since chained masks multiply their alpha, which is how one shape ends up faded on more than one side without hand-building a 2D gradient. The trailing edge, flush with the screen, stays fully opaque. ChartMetrics.priceAxisOverlapraised from 28pt to 40pt (of a ~64pt-wide axis). At 28, over a third of the axis's width had no candle content behind it at all β plain background, nothing to blur β which is what "there's still background under it" was describing. At 40, most of the axis now has real candle content sliding underneath.
Verification
The specific claim to check is narrow and concrete: do the artefacts from the screenshots that prompted this round actually disappear. Material's own light/dark adaptation is well-established and lower-risk than the previous approach, but the exact feathering fractions (15%/8%/8%) and the overlap distance (40pt) are still first-guess numbers, not tuned ones.
Earlier unreleased work
Fixed β glow was washing out candle detail; axis edge still felt hard
Direct follow-up on device feedback to the previous round.
- Crosshair glow switched from filled to stroked. The previous version filled the focused candle's whole silhouette with a blurred, high-opacity additive colour. Blended on top of the real candle drawn underneath, that washed the shape into a bright blob rather than highlighting it β exactly the "blurs out the whole candle" symptom reported. A stroke puts the bright colour in a thin band tracing the outline only; the interior, where the real candle's colour and detail live, is untouched by the glow at all. Both blur radii and opacities were also reduced (outer: 14β10pt blur, 0.45β0.22 opacity; inner: 6β3pt blur, 0.85β0.5 opacity) so the rim reads as a highlight rather than a wash even where it does touch the edge.
- Price axis leading edge now genuinely fades, via
.mask(LinearGradient), instead of stopping at the rounded shape's geometric edge. The fade is narrow β 15% of the axis width β so it reads as an edge dissolve rather than fading out a third of the panel, which would have left the rounded-corner shape doing very little visible work of its own.
Verification
Same caveat as the previous two rounds, plus one addition worth naming directly: .mask(...) on its own is generic and low-risk, but stacking it on a glassEffect view specifically hasn't been ruled out as an interaction neither has been tested independently. And the stroke-based glow's visibility is inherently zoom-dependent β a candle only a few points wide leaves little room for an outline before it just reads as a slightly thicker candle rather than a distinct glow; that's a real tuning question a device is needed to answer, not something more careful reasoning can settle in advance.
Earlier unreleased work
Changed β glass axis revised to real Liquid Glass; crosshair now spotlights
Follow-up on direct device feedback: the flat .ultraThinMaterial price axis "looked weird," and
the glow alone didn't stand out enough against a full-brightness chart.
- Price axis now uses real Liquid Glass on iOS 26+.
glassEffect(.regular, in:)instead of a
plainMaterialfill β genuine specular highlights and light response, not just a blur. Clipped
toUnevenRoundedRectanglerounded only on the leading edge, since that's the one edge that's an
actual boundary against the chart; the top, bottom and trailing edges are flush with the
screen/pane edges, and rounding those (or rounding none of them, the previous behavior) most
likely explains why a plain rectangular blur read as an undefined smudge rather than a panel. - iOS 17β25 fallback improved to match: the same edge-rounded shape, filled with the chosen
Material, with a faint leading-edge stroke for definition a borderless flat fill had no way to
signal.CandleChartStyle.priceAxisMaterial's public meaning is unchanged β it's still the on/off
switch and the fallback material choice β but what it now controls chooses the best available
rendering per OS rather than always usingMaterial. - The crosshair now dims the rest of the chart while glowing the focused candle
(CandleChartStyle.crosshairDimOpacity, new, default0.35;0disables it, keeping only the
glow). The dimmed region is the same shape the glow is drawn from β the glow's geometry doubles
as the "hole" cut into the dim layer via an even-odd fill β so the dimmed boundary and the glow's
soft edge coincide rather than reading as two independently-tuned, potentially mismatched rings.
Dimming covers every pane uniformly; there's no per-pane point highlight (RSI's value at that
candle, say) to carve a matching hole for, which is a deliberate scoping choice, not an oversight. - Drawing order β dim, then glow, then the crosshair's own lines and tags β ensures the glow is
never dimmed by the layer underneath it, and the lines/tags (drawn afterward on the original,
unmodified context) are unaffected by either.
Verification
Genuinely more uncertain than most changes in this project: this is new API usage
(glassEffect(_:in:), confirmed against Apple's own documentation but never compiled) layered on
top of a rendering approach that already needed one round of device-driven correction. Two things
specifically need a look before trusting this: whether the glassEffect branch compiles and looks
right at all on iOS 26, and whether the dim-with-a-hole reads as a spotlight rather than a hard,
distracting edge. Both blur radii, both opacities, and the 16pt corner radius are straightforward
to retune in place if the current values don't look right β see CrosshairLayer.drawFocusGlow and
CandlestickChart.PriceAxisGlassPanel.
Earlier unreleased work
Added β glass price axis and crosshair glow
Two cosmetic changes, both opt-in or additive β nothing here changes default appearance for
existing adopters.
CandleChartStyle.priceAxisMaterial: Material?(defaultnil, unchanged appearance). Setting
it β.ultraThinMaterialis a reasonable start β gives the price axis a frosted backdrop, and lets
candles extend slightly underneath it (seeChartMetrics.priceAxisOverlap, internal, set
automatically only when a material is chosen) so they show through, blurred, as they scroll toward
the edge. Deliberately built on the standardMaterialAPIs (iOS 15+) rather than iOS 26's
glassEffect(), so it works across CandleKit's full iOS 17+ support; on iOS 26 aMaterialstill
renders with the system's current glass materials, just without the newer API's specular
highlights and morphing.- Price axis tick labels moved out of the Canvas into a real view (
PriceAxisGlassPanel, new,
inCandlestickChart.swift). AMaterialonly blurs content behind the view it's attached to β
labels painted into the sameCanvasas the candles would have blurred right along with them.
This is the same reasonLastPriceBadgehad to become a real view rather than Canvas-drawn text.
Labels render identically whether or not a material is set; only the background layer is
conditional. - Crosshair glow. Long-pressing now draws a soft additive highlight around the focused candle,
inCrosshairLayer. Two blurred passes at different radii and opacities (a single blur reads as a
smudge), blended with.plusLighterso it brightens the candle rather than dulling it with an
overlay. Deliberately uses onlyGraphicsContext.Filter.blur(radius:)β a single, simple
parameter β rather than.shadow(...), whose multi-parameter signature hasn't been confirmed to
compile; getting a Canvas filter signature wrong has cost a compile-error round trip before, and
.bluris a much smaller surface to be wrong about.
Fixed during this change
Caught and corrected before packaging, not after: the price axis panel was initially wired to only
appear when a material was set, which meant leaving priceAxisMaterial at its default nil would
have drawn no price labels at all β a real regression, not a stub. Fixed so the panel β and the
labels β render unconditionally; only its background rectangle is conditional on the style.
Verification
None of this has been seen rendered. The layout math (ChartLayout's new priceAxisOverlap) was
checked by hand β with overlap 0 it's provably identical to the previous non-overlapping formula,
since that's the value used whenever priceAxisMaterial is nil, which is every existing
integration. What can't be checked without a device: whether tick labels stay legible against
candles moving underneath the glass, and whether the glow looks like a glow rather than a smudge or
a blown-out blob. Both are exactly the kind of thing that's hard to get right on the first attempt
without seeing it β tune the two blur radii and opacities in CrosshairLayer.drawFocusGlow and the
28pt overlap constant in CandlestickChart freely once you can.
Earlier unreleased work
Added β indicator panes (Phase 5.3)
RSI, MACD, Stochastic, ATR and OBV now actually draw. They were computed and cached since the
previous change but skipped by the renderer, because plotting 0β100 values against a price scale
would have been worse than plotting nothing. This closes that gap.
- Stacked panes below the candles, one per separate-pane indicator, each autoscaled to its own
values with its own grid, value axis and title. - The price pane keeps what's left, and indicator panes are capped at 60% of the content height
no matter how many are added or how tall each asks to be β otherwise four indicators squeeze the
candles into a sliver, which is the one thing the user is actually looking at. - Reference levels widen a pane's range, so RSI's 70 line can't scroll off screen when the
values are all low. A pinnedpreferredRange(RSI's 0...100) stays exactly pinned rather than
drifting outward by the padding fraction on every render. - The crosshair spans every pane β the candle under your finger lines up with its RSI or MACD
value below β while the horizontal line and value tag belong to whichever pane the finger is
actually in. - Gestures now cover the whole content area, so a drag starting on an indicator pane pans the
chart instead of doing nothing. - Each pane is clipped to itself, so an indicator briefly exceeding its autoscaled range can't bleed
into the candles above. - The indicator drawing routines are now parameterised on a value mapping rather than reaching for
the price scale, which is what lets panes and price overlays share one implementation. - New Core helper
PriceScale.autoRange(forSeries:in:required:paddingFraction:), with tests. - Demo: RSI and MACD toggles under a new "Panes" section on the Market tab.
Known gaps
Panes aren't resizable or collapsible yet, volume can't move into its own pane, every separate-pane
indicator gets its own pane rather than being mergeable, and VoiceOver still reads only the price
rather than each pane's value at the crosshair. Tracked as roadmap task 5.11.
One cosmetic edge: if an indicator has no visible values at all (fully inside its warm-up period),
its pane is laid out but left empty rather than collapsed. It reclaims the space as soon as values
appear.
Verification
The new Core helper is covered by tests you can run with swift test, including the pinned-range
and no-visible-values cases. Everything else β pane layout, the spanning crosshair, gesture
coverage β is uncompiled and needs an iOS build and a look on device.
Earlier unreleased work
Changed β rendering layer migrated onto the indicator engine (Phase 5.10)
The engine added in the previous entry is now what the chart actually draws.
ChartIndicatorwraps anyIndicator, including app-defined ones, with optional colour and
line-width overrides and anisVisibleflag (so a settings UI can toggle an indicator without
discarding its configuration). Convenience factories added for the whole Tier 1 set:
.bollingerBands(),.vwap(),.rsi(),.macd(),.stochastic(),.atr(),.obv(),
alongside the existing.sma()/.ema()/ new.wma().IndicatorCacheis keyed by descriptor, so changing one indicator's period recomputes only
that indicator instead of invalidating every overlay on the chart. Covered by new tests that
assert the computation count directly β a cache that silently recomputes every frame is
indistinguishable from a working one without them.- The renderer draws the full output model: lines (with dash patterns), stepped lines,
sign-coloured histograms, point markers, fills between plots, and horizontal reference levels.
Histograms are batched into two paths by sign, following the same constant-draw-call rule as the
candles. - Autoscale now accounts for indicator overlays. A long moving average on a trending series
routinely sits outside the visible highs and lows; previously it would be clipped. CandleChartStylegainedindicatorPalette, so consecutive overlays are visually distinct
without the app picking colours.- Only visible indicators are computed β hiding one stops paying for it.
IndicatorKindandMovingAverageare deprecated but still work, and now delegate to
IndicatorMathso there is one implementation rather than two that can drift. Existing
.indicators([.sma(20), .ema(50, color: .blue)])call sites are unchanged.- Demo: Bollinger Bands and VWAP toggles on the Market tab, which exercise the multi-plot and fill
paths that a plain moving average never touches.
Known gap
Indicators that request their own pane β RSI, MACD, Stochastic, ATR, OBV β are computed and cached
but not drawn. The renderer skips anything not on the price pane, because plotting 0β100 RSI
values against a price scale would be worse than plotting nothing. Roadmap task 5.3 (indicator
panes) closes this and is the next task; until then, adding .rsi() to a chart will correctly
produce no visible output rather than a wrong one.
Verification
The Core half β the new cache, its eviction and per-descriptor recomputation β is covered by tests
you can run with swift test. The rendering half is uncompiled as usual: the plot styles, fill
geometry and colour resolution need an iOS build and a look on a device.
Earlier unreleased work
Added β indicator engine (Phase 5.1, 5.2, most of Tier 1)
The foundation the indicator picker, saved layouts and indicator panes all depend on. All pure
Foundation in CandleKitCore, so this is the first work in a while you can actually verify
yourself: run swift test.
Indicatorprotocol and output model (IndicatorOutput.swift,Indicator.swift). The old
IndicatorKind.values(for:) -> [Double?]returned one number per candle, which cannot express
Bollinger's three lines and a fill, MACD's histogram, RSI's pinned 0β100 pane with reference
levels, or SAR's discrete dots. The new model covers plots (line, stepped, histogram, points,
hidden), fills between plots, reference levels, a preferred scale range, and pane placement.
Colours are expressed as roles rather thanColorvalues, so Core stays UI-framework-free and
Linux-testable.- Typed parameters (
IndicatorParameter.swift) β the piece that makes a generic picker
possible. An indicator declares its inputs with kinds and valid ranges, so the settings form can
be generated for any indicator, including one an app defines itself, with no bespoke UI. Values
clamp on assignment, so a picker binding can't put an indicator into a divide-by-zero state. IndicatorCatalogβ immutable andSendable(no global mutable registry, so no locking and
no Swift 6 concurrency problem). Apps extend it withadding(_:), and two charts in one app can
offer different sets. Provides category grouping and search for the picker.IndicatorDescriptorβ theCodableidentifier + [key: value]pair a saved layout stores.
Unknown parameter keys are ignored and out-of-range values clamped on load, so a layout written
by a newer build, or a hand-edited one, degrades gracefully instead of failing.IndicatorMathwith SMA, EMA, WMA, Wilder smoothing, rolling population standard deviation,
RSI, MACD, Stochastic, true range, ATR, Bollinger Bands, OBV and session-anchored VWAP. Every
function documents which convention it implements, and where two are common the choice is a
parameter rather than a silent decision.- Tier 1 indicators as
Indicatorconformances:MovingAverageIndicator(SMA/EMA/WMA),
BollingerBandsIndicator,VWAPIndicator,RSIIndicator,MACDIndicator,
StochasticIndicator,ATRIndicator,OBVIndicator. - Tests covering the maths, the parameter clamping, catalog round-tripping through
Codable,
the app-defined-indicator extension point, and degenerate inputs (empty series, single candle,
mismatched array lengths, zero periods).
Verification, and one thing that isn't verified
The O(n) WMA recurrence was checked against a direct computation of the definition over 200 random
inputs before being written, and the same check ships as a test. The expected values in
IndicatorMathTests.swift come from an independent reference implementation written separately
from the Swift, so the two agreeing is genuine evidence rather than the Swift agreeing with itself.
What is not established: that these numbers match what a user sees on TradingView. Validating
RSI against a widely reproduced published worked example came out consistently ~0.07 off, and
neither an alternative seeding convention nor intermediate rounding explained the gap. The most
likely explanation is that the recalled table was inaccurate, but that cannot be confirmed without
the primary source. Rather than bake in numbers of uncertain provenance, the tests verify internal
consistency and the documented formulas, and roadmap task 5.9 now tracks validating against an
authoritative source as required work β not optional polish.
Changed
- Nothing was removed.
IndicatorKindand the existing rendering path still work exactly as before;
the new engine was added alongside it. Reconciling the two is roadmap task 5.10, and has to
happen before panes or the picker can ship.
Decided
- CandleKit will ship the indicator picker and drawing toolbar, in a separate
CandleKitUI
product so the core stays headless. Recorded in the roadmap's decision log; open question 9 is
closed.
Earlier unreleased work
Added β indicators, drawing tools, configuration and persistence roadmap
Phases 5β7 substantially expanded and restructured around what developers adopting a charting
library actually need.
- Phase 5 (indicators) now leads with architecture, not catalog.
IndicatorKind's current
[Double?]-per-candle output can't express Bollinger Bands, MACD's histogram, Ichimoku's cloud or
Parabolic SAR's dots, so a protocol and a richer output model (multi-line, band fill, histogram,
point markers, reference levels) must land before any new indicator, or each one becomes migration
debt. Catalog split into Tier 1 (the twelve that actually get used) and Tier 2 (the long tail,
mostly good community-contribution material). Custom indicators folded into the architecture work
rather than bolted on afterwards. - Indicator conventions called out explicitly. RSI smoothing, ATR smoothing, MACD signal seeding
and Stochastic %D all differ between platforms. Every indicator must document which convention it
implements and test against cited reference values β a chart that disagrees with TradingView's
numbers gets reported as a bug. - Phase 6 (drawing tools) restructured the same way: interaction model,
(Date, price)anchoring,
hit testing, undo, and magnet/snapping first; then Tier 1 (horizontal line, ray, trend line,
rectangle, Fibonacci retracement, text, measure) and Tier 2. - New Phase 7 β configuration, persistence and comparison, promoted ahead of platform expansion:
ChartConfigurationmaking every capability individually switchable, replacing the ad-hoc
volumeVisible/headerVisiblemodifiers before the surface grows further.- An open question on whether CandleKit ships default UI (indicator picker, drawing toolbar) β the
recommendation is a separateCandleKitUIproduct so the core stays headless. ChartLayout: Codable, versioned with a migration path from day one.- Persistence recommendation: keep Core Data and SwiftData out of the core library. Ship
Codableas the contract with an optionalCandleKitPersistencecompanion providing SwiftData
wrappers. Reasoning is written out in 7.4 β adopting apps already have a persistence stack, a
library-owned schema creates migration coupling, and aCodablevalue can be stored anywhere
while a SwiftData model cannot. - Multi-symbol comparison (moved from 9.2), expanded with normalization modes and the genuinely
hard part: aligning series with different trading calendars under an index-based x-axis.
- Scope note added to the preamble. Phases 5β7 are more work than Phases 0β4 combined. The plan
states plainly that the extensibility API matters more than catalog size, and that Tier 1 finished
properly beats both tiers half-finished. - Old Phase 7 (More platforms) renumbered to Phase 11; all cross-references updated.
- Four new open questions: whether CandleKit ships UI, confirmation of the persistence stance, the
comparison-view API boundary, and whether matching TradingView's numbers or textbook definitions
wins when indicator conventions conflict.
Added β competitive roadmap
docs/ROADMAP.md gains three new phases (8, 9, 10) plus a positioning section, laying out what
would make CandleKit a better choice than TradingView's Advanced Charts or SciChart iOS rather than
just a free imitation of either:
- Phase 8 β signature features: things a
WKWebView-hosted chart structurally cannot do on
Apple platforms β a home screen widget, a Live Activity, a watchOS companion, App Intents/Siri
integration, chart-as-image sharing, and deeper visionOS use of depth. - Phase 9 β feature-parity rounding: real gaps against professional charting tools that Phases
4β6 don't already cover β extended-hours shading, multi-symbol comparison overlays, event
markers, a price-crossing primitive for alerts, and a replay/bar-by-bar playback mode. - Phase 10 β developer experience:
Codablechart-layout persistence, a testing-support
module, asset-class-aware number formatting, and a localization/RTL pass β the axis proprietary
SDKs consistently under-invest in. - An explicit "What CandleKit deliberately will not become" list β order books, bundled data
feeds/brokers, a built-in alerting backend, and remote-config styling are called out as
out-of-scope for the core library, with a suggested path (companion packages) for whichever of
them someone eventually wants. - The competitive claims are grounded in each competitor's own current pricing/licensing pages
rather than assumed, and cited in the roadmap.
All of this is explicitly sequenced after the 1.0 criteria, not folded into getting there β
nothing here should pull focus while the project hasn't built yet (Phase 0).
Earlier unreleased work
Fixed β CI build failure
animatedResetZoom() and animatedScrollToLatest() were declared with no access modifier
(internal, Swift's default) when added a few commits back for the double-tap-to-reset gesture,
which only ever called them from within the CandleKit module. A later change (this changelog's
own "Jump to latest" and "Reset zoom" entries) had the Demo call them directly from its own module,
which needs public β an oversight on my part, since I recommended that change without checking
the methods it was calling were actually exposed. Xcode Cloud caught it correctly:
error: 'animatedResetZoom' is inaccessible due to 'internal' protection level
error: 'animatedScrollToLatest' is inaccessible due to 'internal' protection level
Both are now public, with doc comments explaining when to prefer them over the instant
resetZoom() / scrollToLatest(). Audited every other CandleKit symbol the Demo references
against its declared access level β nothing else is affected.
Earlier unreleased work
Fixed β docs
docs/PERFORMANCE.md: thexctraceexample was wrong in two ways.--outputwas placed after
--launch --, where everything is passed to the launched app, so it was silently ignored and
traces landed under auto-generated names. And--launch --takes a path to the.appbundle, not
a bundle identifier.- Made it explicit that Animation Hitches requires a physical device β on the Simulator it fails
withHitches is not supported on this platform, because hitch measurement reads real display
pipeline timing. Added a section on what the Simulator can and can't tell you (gross algorithmic
problems yes; relative phase cost no, since CPU work runs on the Mac's cores while rendering takes
a different path), an--attachrecipe that's easier for interactive profiling, the Xcode GUI
route as the recommended default, and a troubleshooting table.
Earlier unreleased work
Added β performance instrumentation
Scrolling and the appear animation are reported fixed. This adds the measurement infrastructure
that should have come first, so the next round of performance work starts from numbers.
ChartPerformance: opt-in instrumentation naming twelve phases of the per-frame work
(makeFrame,makeFrame.priceRange,draw.candles,draw.axes,draw.crosshair, β¦). Emits
OSSignposterintervals for the Points of Interest track in Instruments, and collects timings
thatChartPerformance.report(scenario:)renders as a markdown table with call counts and
mean/p50/p95/max per phase.- Off by default, costing one
Boolcheck per phase. Enable with theCANDLEKIT_PERFenvironment
variable β so a Release build can be profiled without recompiling β orsetEnabled(_:). - Demo: a capture bar on the Performance tab (record β interact β stop β copy). Reports are tagged
with the device model, OS version and candle count, and simulator runs are labelled as not
representative.UIDevice.nameis deliberately not used, since it's often the owner's real name
and these reports get pasted into a public repo. docs/PERFORMANCE.mdrewritten: phase reference, in-app capture recipe, Instruments recipe
(including anxctraceinvocation), how to read the numbers against a 120 Hz budget, the two
known-remaining costs, and a trace log with a template. The log is empty β the first entry should
be a baseline on currentmain.CLAUDE.md: performance changes now require a before/after trace entry in the same change.
Earlier unreleased work
Performance β second pass (scrolling, zooming, appear animation)
Follow-up after the first pass didn't resolve the reported lag. See docs/PERFORMANCE.md for the
ranked hypothesis list, what's still open, and β more importantly β the Instruments recipe. None of
the below is measured; that document explains why measuring is now the priority over further
blind fixes.
- The whole chart re-rendered on every animation frame.
CandlestickChart.bodyread
state.revisionat the top, so each bump β one per frame during a pan, fling or pinch, up to 120
a second β invalidated the entire body: the header (which reformats a row of prices), the
accessibility summary, theUIViewRepresentablegesture host, and the ZStack layout. Almost none
of that changes when the viewport moves.revisionis now read only by a new private
ChartContentLayer, the one view that genuinely must repaint per frame. This is the largest
single change in this pass and the most likely explanation for lag that survived the first round. - Accessibility strings were built on every frame.
ChartAccessibility.summaryformats two
dates and two numbers; date formatting is among the most expensive operations in Foundation, and
this ran on every frame of every scroll to produce a string only VoiceOver reads. It's now
attached only whenaccessibilityVoiceOverEnabledis true β and when VoiceOver is running
there's no 120 Hz flinging to protect, so it stays fully accurate. - Appear animation issued a draw call per candle.
drawAnimatedCandlescopied the
GraphicsContextand issued two or three fills for every visible candle, every frame β over a
thousand draw calls per frame at wide zoom, which is exactly why the animation stuttered worst
when the most candles were on screen. Candles are now grouped into 8 quantised opacity buckets
and drawn as batched paths, capping it at a couple of dozen fills. The fade zone spans only a few
candles, so the quantisation isn't visible. - Axis labels ran
FormatStyleinside the draw closure. Every price and time label was
re-formatted on every frame. Label strings are now built once inmakeFrame, cached against the
ticks that produced them (so panning, where tick values usually don't change at all, skips the
work entirely), and passed to the renderer pre-formatted. The renderer no longer calls
ChartFormatat all. - The crosshair Canvas re-rasterised even when idle.
CrosshairLayeralways created aCanvas
whose draw closure returned immediately when no crosshair was showing β but the Canvas itself
still re-rendered every frame. It now produces no view at all when idle, and readsrevision
only while a crosshair is actually up.
Added
docs/PERFORMANCE.md: ranked hypotheses, the two known-remaining costs with concrete options for
each, and a step-by-step Instruments recipe.
Earlier unreleased work
Fixes from a pass looking specifically for memory leaks, hangs, and scrolling/animation smoothness,
prompted by reports that panning and animations didn't feel smooth. See docs/ROADMAP.md for the
full list of what's still open, and the "Verification" note at the end of this entry for what has
and hasn't been checked on a device.
Fixed
- Memory leak: chart animations could keep a whole chart alive forever.
CandleChartState's
appear-in and spring-to-target animations usedCADisplayLink(target: self, ...).
CADisplayLinkretains its target for as long as it's on a run loop; nothing invalidated these
two if the chart was removed from the view hierarchy mid-animation (a list row scrolling away, a
sheet dismissed, a navigation pop, all while data had just loaded or a "reset zoom" was still
settling). Both are now driven through a small weak-referencing proxy (DisplayLinkProxy, new
file) that never keeps the chart alive, andCandlestickChartnow also calls
state.stopAnimations()(new public method) from.onDisappear, so the common case cleans up
immediately rather than only being leak-safe. The gesture coordinator's own two display links
(momentum, rubber-band release) got the same treatment for consistency, even though their
cleanup viadismantleUIViewwas already sound. - Scrolling: flinging to the newest or oldest candle stopped dead instead of bouncing. Every
other place the chart touches the data edge gives some kind of soft-limit feedback β dragging
past the edge rubber-bands, a pinch past the zoom limit gives a haptic tick β except a fling that
reached the edge under its own momentum, which just stopped. Momentum now hands off to the same
rubber-band spring used for a drag release the instant it crosses the boundary, so it bounces
like the rest of the chart instead of reading as a glitch. - Scrolling: a slow, deliberate drag could get mistaken for a long-press. The crosshair's
minimumPressDurationhad been lowered from0.25to0.15seconds, which is close enough to
how long a finger dwells before an intentional slow drag satisfiesUIPanGestureRecognizer's own
movement threshold that panning would occasionally lose the race and open the crosshair instead.
Reverted to0.25. - Redundant work on every render.
ChartHeaderrecomputedTimeScale.estimatedIntervalfrom
scratch on every render β including every crosshair move and every live price tick β duplicating
workCandleChartState.makeFramehad already done and cached for the same data. It now reads the
cached value. - Overzealous haptics and sound. A recent pass had added
AudioServicesPlaySystemSound(undocumented
system sound IDs, not public API) plus layered haptics to nearly every gesture: a pop on long-press
start, a peek on release, a click on every pinch past the zoom limit, a peek on every momentum
edge-hit. Reaching the edge during ordinary scrolling is common, so that last one in particular
fired on a large fraction of everyday scrolls. All system sounds are removed, and haptics are
pared back to three: one light tap when the crosshair engages, one rigid tap at a hard zoom limit,
one medium tap on double-tap-to-reset. Per-candle crosshair ticks still come from
CrosshairLayer's existing.sensoryFeedback(.selection), unchanged.
Added β animated price ticker
- The last-price tag (the badge on the price axis showing the current close) and
ChartHeader's
price, change and percent values now use.contentTransition(.numericText()): digits roll up or
down instead of cross-fading, the standard "stock ticker" look, on every live tick. - This required moving the last-price tag out of the Canvas into a real SwiftUI view
(LastPriceBadge, new)..contentTransitionis a view modifier with no Canvas equivalent β
Canvas-drawn text is rasterised immediately and can't animate.ChartHeader's prices were already
real views, so those only needed the modifier added. - Scoped deliberately to the numbers people actually watch tick, not every number the chart
draws. Axis tick labels and the crosshair's price/time tags stay Canvas-drawn:- Tick labels are a poor fit for
numericTextregardless β the set of visible ticks changes
identity as the price range autoscales or the user pans, which is an insertion/removal, not a
value transition on a stable view. - The crosshair's tags already update at gesture-frame-rate as a finger drags; animating those
would fight the drag rather than help it, and would reintroduce a per-frame real-view cost in
exactly the place the previous two passes worked to remove it. - If axis tick labels ever move to real views, it'll most likely be to fix the "text drawing in
Canvas" performance question (seedocs/PERFORMANCE.md), not for this animation β worth doing
together if a trace showsdraw.axesis hot, since the same view-based fix would clear both.
- Tick labels are a poor fit for
LastPriceBadgereadsstate.revision, unlike most ofCandlestickChart.swiftβ its vertical
position needs to track the price scale during a pan or pinch. That's a deliberately narrow,
cheap subscription (repositioning one small view), not the whole-subtree cost the render-scope
refactor in the previous pass exists to avoid.
Earlier unreleased work
Added
- Appear-in and reset/scroll-to-latest animations now check
UIAccessibility.isReduceMotionEnabled
and skip straight to the end state when it's on, matching the demo's own build of the same
feature for the reveal sweep. Direct-manipulation motion (the rubber-band drag itself, ordinary
momentum) isn't gated, matching howUIScrollViewbehaves. CandleChartState.stopAnimations(): stops any in-flight appear or spring animation without
touching the current viewport. Called automatically byCandlestickCharton disappear; also
useful directly if you manage chart state outside aCandlestickChart's own lifecycle.
Changed (demo)
- The "Jump to latest" button and the chart-options menu's "Reset zoom" now call
animatedScrollToLatest()/animatedResetZoom()instead of the instant versions, so they match
the feel of double-tap-to-reset instead of snapping.
Verification
None of this has been compiled β this environment has no Swift toolchain (see docs/ROADMAP.md,
Phase 0). Everything above was checked by careful reading, not by running it: the numeric spring
and rubber-band logic was traced by hand for the cases that looked riskiest (a stale rubber-band
flag surviving into an unrelated gesture, a momentum hand-off with too little overscroll to bounce
visibly), and the concurrency pattern in DisplayLinkProxy mirrors one already in
Demo/CandleKitDemo/Performance/PerformanceView.swift. It has not been verified that any of
this actually compiles under Swift 6's strict concurrency checking, and none of it has been felt on
a device. Before relying on this: run the Phase 0 build steps, then work through
docs/QA.md's momentum/rubber-band/crosshair/Reduce-Motion items specifically, since those are
exactly what changed here.