| name | pine-to-typescript-port |
| description | Use when porting a TradingView Pine Script indicator (anchored VWAP, ATR bands, RSI divergence, custom Level, etc.) to TypeScript for a lightweight-charts based app. Pine is a bar-by-bar series-oriented DSL with specific semantics — anchor-reset, ohlc4, dotted vs dashed, color.new, plot vs hline, request.security() — that don't translate one-to-one to vanilla TS. This skill names the Pine concepts, their TS equivalents, and the gotchas that cost iterations. |
| metadata | {"category":"charting"} |
Porting Pine Script to TypeScript
Pine Script is TradingView's bar-by-bar series DSL. It has powerful primitives (anchor-reset, request.security(), plot() overloads, color helpers like color.new()) that compile to TradingView's renderer. When porting a Pine indicator to a custom TypeScript app — typically with lightweight-charts as the renderer — naive translation produces "looks like Pine but is wrong" output. The semantics need to be ported, not just the syntax.
This skill catalogs the non-obvious concept mappings learned while porting an Anchored-VWAP / Level indicator (with std-dev bands, mitigation lines, zone boxes, and per-anchor labels).
When to use
Reach for this skill if you're:
- Porting any Pine indicator to TypeScript (or any non-Pine renderer)
- Implementing "Anchored VWAP" / "Level" / "Session VWAP" / similar
- Trying to match a Pine indicator's output pixel-for-pixel
- Confused why your TS port "looks 90% right but X is wrong"
The pattern — concept-by-concept mapping
1. Bar series + anchor-reset
Pine's signature trick: var float cum_pv = 0.0 with an if anchor_changed : cum_pv := 0.0 resets the cumulator at each anchor boundary (new day, new session, new week, etc.). Each anchor period builds its own VWAP from scratch.
In TS: walk the bars once, detect anchor boundaries, reset cumulators. Use functions like localDayKey() to detect "this bar is in a different day from the previous" — relative to the right timezone.
function anchorKey(unixSeconds: number, anchor: AnchorTf, tz: string): string {
}
let cumPV = 0, cumV = 0, prevKey: string | null = null;
for (const bar of bars) {
const key = anchorKey(bar.time, anchor, tz);
if (key !== prevKey) { cumPV = 0; cumV = 0; }
const typical = (bar.open + bar.high + bar.low + bar.close) / 4;
cumPV += typical * bar.volume;
cumV += bar.volume;
vwap[i] = cumPV / cumV;
prevKey = key;
}
2. ohlc4 and other Pine source helpers
Pine has close, open, high, low, hl2, hlc3, ohlc4, volume. These map directly:
| Pine | TypeScript |
|---|
close | bar.close |
hl2 | (bar.high + bar.low) / 2 |
hlc3 | (bar.high + bar.low + bar.close) / 3 |
ohlc4 | (bar.open + bar.high + bar.low + bar.close) / 4 |
Anchored VWAP almost always uses ohlc4 as the typical price. If the Pine source says close, port it as close — don't "improve" it by switching to ohlc4.
3. Standard deviation bands
Pine's anchored-VWAP-with-bands indicators use a running variance:
Var = E[X²] − (E[X])²
= cum_pv2 / cum_vol − vwap²
The trick is the second moment cumulator: cumPV2 += typical * typical * volume. Then sd = sqrt(cumPV2 / cumV - vwap*vwap) gives bands at vwap ± mult * sd. This is numerically more efficient than re-walking bars to compute variance.
4. plot() vs hline() vs custom series
Pine has multiple drawing primitives:
| Pine call | What it draws | lightweight-charts equivalent |
|---|
plot(value, style=plot.style_line) | Per-bar series | ISeriesApi<"Line"> or a custom series for multi-line |
plot(value, style=plot.style_stepline) | Stepped line | Custom series renderer; v5 lacks built-in |
hline(value) | Horizontal at a fixed price | IPriceLine via series.createPriceLine({...}) |
bgcolor() | Background tint | Custom series renderer; v5 lacks built-in bgcolor |
label.new() | Anchored text label | DOM overlay div positioned via timeScale.timeToCoordinate |
box.new() | Rectangle (zone box) | Custom series renderer (canvas rect) OR DOM overlay div |
For indicators with MANY plot primitives per anchor (e.g. drawing ~60 segments per anchor), using one chart series per segment is slow. Roll them into a single ICustomSeriesPaneView that draws everything in one Canvas2D pass. See lightweight-charts-integration.
5. Color helpers
| Pine | TypeScript / CSS |
|---|
color.new(c, transp) where transp is 0-100 | withAlpha(c, 1 - transp/100) — Pine inverts the alpha convention! |
color.green / color.red | Hex from your design tokens, NOT a name |
color.from_gradient(...) | Compute a hex blend at the right step |
The transparency inversion is non-obvious. Pine's color.new(c, 85) means "85% transparent" → output alpha = 0.15. JS's typical rgba(..., 0.85) means "85% opaque" → output alpha = 0.85. Read the Pine source carefully; port to your withAlphaCss(color, 1 - pineTransp/100).
6. request.security() — higher-timeframe context
Pine's request.security(syminfo.tickerid, "D", close) fetches the daily close while running on a 5m chart. There's no direct TS equivalent — you need a separate data fetch for the higher TF, joined to your bars by time.
For an Anchored-VWAP that's anchored to daily/weekly anchors, you DON'T need request.security() — you can detect the anchor from the bar timestamps themselves (anchorKey(bar.time, "D", tz) returns a date key). Only reach for the dual-data-source pattern if the indicator computes something on the HTF and overlays on the LTF.
7. var vs regular assignment
Pine's var float cumulator = 0.0 declares a persistent-across-bars variable. Regular cumulator = 0.0 reassigns on every bar. In TS this is the difference between a let outside the loop (persistent) vs inside (per-iteration). Don't confuse the two.
8. na (not-available) handling
Pine has a first-class na value distinct from 0. TS doesn't. The right port is NaN for numeric "no value," and the chart-side renderer needs Number.isFinite() checks before drawing. See lightweight-charts-integration for the isWhitespace gotcha — DON'T strip NaN rows from data.bars; let the renderer skip them at draw time.
9. "Same code path in live + backtest + ML"
If your indicator runs in production, in backtests, and in ML feature pipelines, the same code path must produce the same numbers in all three. Don't fork — write the core computation once and call it from each context.
Workflow — the right port sequence
1. Read the Pine source. ALL OF IT. (See read-reference-source-first.)
Use mcp__pinescript__pine_search / pine_reference / pine_examples if
the source uses any unfamiliar built-ins.
2. Annotate the Pine. Comment in the TS port file naming the Pine
lines you're translating from. Easier to verify line-by-line.
3. Translate the math. Start with the cumulator-reset loop and the
value computations. Skip styling for now.
4. Translate the drawing. Map plot/hline/box.new/label.new to your
renderer's primitives. Pick "one custom series per indicator
instance" if drawing > ~10 primitives per bar.
5. Translate the styling. Colors, line widths, dash patterns, alpha
conventions. Pine's transp=85 ≠ alpha=0.85.
6. Browser-verify against the Pine reference. Take a screenshot of
the Pine indicator on TradingView. Take a screenshot of your TS
port. Diff visually. ANY divergence is a bug to find.
Gotchas
- Color transparency inversion. Pine
color.new(c, 85) = 15% opaque. Easy to miss.
bar_index vs time. Pine's bar_index is the sequential bar number. Your TS chart probably uses time. Don't confuse them in width calculations.
time() returns ms in Pine, but your bars probably use seconds. Watch for the 1000× factor.
syminfo.session.regular semantics. Pine knows about RTH (regular trading hours) vs ETH (extended). If the indicator uses session filtering, you need a bar.in_rth flag (computed from the bar's NY-local time vs the symbol's RTH window) before you can port.
- Pine's stepped-line vs lightweight-charts. Pine's
plot(..., style=plot.style_stepline) renders a step. lightweight-charts has no built-in step series; you draw it in a custom series.
- Don't over-engineer. Many Pine indicators have decorations (label boxes, info tables, alert conditions) that aren't core to the math. Implement the math first, ship a minimal renderer, add decorations iteratively.
- Mitigation / touched-state. Many Pine indicators track "has price touched this level since the anchor closed?" via a per-anchor flag. Port this state EXPLICITLY — don't let it emerge from re-walking the bars in the renderer.
Reference implementation
The mapping above was extracted while porting an Anchored-VWAP "Level" indicator (multi-anchor: 15m, 2H, D, W, M, Q) with std-dev bands, mitigation horizontal lines, zone boxes, and per-anchor text labels. The eventual TS shape:
- A pure
anchored-vwap.ts module with computeAnchoredVwap(bars, settings) returning the value series + per-bar metadata (anchor-close flag, mitigation touch state)
- A
level-custom-series.ts ICustomSeriesPaneView that consumes the metadata and draws everything in one Canvas2D pass (with isWhitespace returning false; Number.isFinite guards inside draw())
- A separate DOM overlay layer for the few primitives that don't fit in a canvas series (text labels positioned via
timeToCoordinate)
- Parity tests against known Pine values for a handful of anchor windows
Related skills
read-reference-source-first — read the Pine source FIRST. Multiple times. This is THE highest-leverage step.
lightweight-charts-integration — the chart-side primitives (custom series, time domains, isWhitespace) the ported indicator targets
cme-futures-time-buckets — if the indicator anchors to D/W/M, the bucket math has to respect futures sessions, not calendar boundaries
browser-verify-ui-changes — the right verification for "does my port match the Pine?"