Skip to content

The library

Installing the library

What is in the package, which of the three builds to point your bundler at, and the mounting code for each framework. The core is the same file in every case; what changes is roughly twenty lines of wrapper.

Getting it

The library lives in a private repository, WarburgAI/vertical-ultracharts-library, and your access is a read invitation on the GitHub account you gave us. Accept it and you can clone, submodule or install from it like any other private dependency. There is no registry, no token to manage and no credential from us to rotate: your own GitHub authentication is what reaches it.

As a submodule, which is how most integrations do it
git submodule add https://github.com/WarburgAI/vertical-ultracharts-library.git vendor/vp-charts
git -C vendor/vp-charts checkout vp-charts-v.5.0
Or through your package manager
npm install github:WarburgAI/vertical-ultracharts-library#vp-charts-v.5.0

Pin the tag. Each release is a new tag and the branch tip moves with it, so tracking the branch means your users get a new chart engine on a deploy you did not plan. You move when you choose.

The repository root is the build itself rather than a folder inside it, which is what lets the verification below run against the checkout you already have instead of a separate download you would have to keep beside it.

What is in the package

A directory you serve. There is no install step that reaches a registry, no postinstall script, and no runtime dependency: the chart is one bundle, one stylesheet and a set of chunks it loads on demand.

FileWhat it is
compat.jsThe entry, as a native module. Code-split: it imports the rest of the chart from chunks/ as features are used, so a chart nobody opens the drawing tools on never downloads them.
chunks/The lazily-loaded remainder. Content-hashed filenames, so they can be cached permanently and a deploy cannot serve a stale one.
compat.global.jsThe same chart as ONE file that defines window.VPCharts. No bundler, no module loading, nothing else to fetch.
vp-charts.cssThe stylesheet. Required: without it the chart renders unstyled chrome.
compat.d.tsTypeScript declarations for the whole surface, plus compat.global.d.ts for the script-tag build.
sandbox-guest.htmlDeploy to a DIFFERENT site and point script_sandbox.frameUrl at it. Only needed if you run indicators your users wrote; see the note below.
community.jsThe run loop for indicators other people published: what may run, when a takedown lands, and what to do when the server cannot be reached. Import it only if you offer them. See [below](#community).
live/The live chart: a small animated chart for cards, tickers and landing pages, beside the full one. live/index.js for a bundler, live/react/index.js for React, live/vp-live.global.js for a script tag, each with its declarations. See The live chart.
compatibility-report.jsonEvery option and method with its status, generated from the same build.
release-manifest.jsonEvery shipped file with its size and SHA-256, beside a detached signature. See [verifying](#verify).

Three builds, one chart

The same code, packaged for three different constraints. Pick by what your build can do, not by size alone.

BuildUse it whenTransfer
compat.jsYou have a bundler, or you can serve modules. The default choice.~112 KB gzip on first load, the rest on demand
compat.global.jsNo build step: a <script> tag on a page, or a host that cannot serve a directory of chunks.~215 KB gzip, everything, up front
vp-charts.cssAlways, alongside either.~15 KB gzip
The first-load figure is the entry plus the chunks a chart needs before it can draw anything, not the entry alone: the rest arrives when a reader opens the drawing tools or adds an indicator. The single-file build is larger than that first load and smaller than the total, because a single file cannot be split, so everything a chart might do is in it whether or not the reader does it. That is the whole trade, and it is a good one when the alternative is not shipping. Both figures are measured on the current release and move with it; the build enforces a ceiling on the first one.

The package is the same for every framework

There is no React build and no Vue build. The chart is framework-agnostic and mounts into a plain DOM element, so what changes between stacks is only WHERE you call it from and WHEN you tear it down.

Three rules cover every framework, and every recipe below is those three rules in that framework's vocabulary. Create the widget only once the element is in the document, because the chart measures it. Keep the handle, because everything afterwards goes through it. Call remove() on teardown, because a chart that outlives its container keeps its subscriptions and its animation frames, and the next mount adds a second one.

Vanilla JavaScript

The script-tag build, which needs no bundler and defines one global.

A chart with no build step
<link rel="stylesheet" href="/vp-charts/vp-charts.css" />
<div id="chart" style="height: 520px"></div>

<script src="/vp-charts/compat.global.js"></script>
<script>
  const widget = new VPCharts.widget({
    container: document.getElementById('chart'),
    symbol: 'BTC-USDT',
    interval: '60',
    // No default feed exists. Yours, implementing the datafeed contract.
    datafeed: myDatafeed,
  });

  widget.onChartReady(() => {
    widget.activeChart().createStudy('Moving Average', false, false, [200]);
  });
</script>

With a bundler, the same thing is import { widget } from '@warburgai/vp-charts' against compat.js, and the stylesheet becomes an import too.

React

Create it in an effect against a ref, and RETURN THE DISPOSER. That last part is not a tidiness rule: a chart that survives its unmount keeps running, and in development the double-invoked effect means the first paint has two charts stacked on one element.

A chart component
import { useEffect, useRef } from 'react';
import { widget as VPWidget } from '@warburgai/vp-charts';
import '@warburgai/vp-charts/css';

export function Chart({ symbol }: { symbol: string }) {
  const el = useRef<HTMLDivElement>(null);

  useEffect(() => {
    // The element is in the document by now, which is what the chart needs:
    // it measures its container on construction.
    const widget = new VPWidget({
      container: el.current!,
      symbol,
      interval: '60',
      datafeed: myDatafeed,
    });

    // REQUIRED. Without it the chart outlives the component, keeps its
    // subscriptions, and the next mount stacks another one on top.
    return () => widget.remove();
  }, [symbol]);

  return <div ref={el} style={{ height: 520 }} />;
}
The dependency array is the second thing to get right. symbol is in it here, so changing instrument REBUILDS the chart, which throws away the reader's drawings and their zoom. Leave it out and call widget.setSymbol() in a separate effect instead, unless a full reset is what you meant.

Vue

The same three rules, in onMounted and onBeforeUnmount.

A chart component
<script setup>
import { onMounted, onBeforeUnmount, ref } from 'vue';
import { widget as VPWidget } from '@warburgai/vp-charts';
import '@warburgai/vp-charts/css';

const el = ref(null);
let widget = null;

onMounted(() => {
  widget = new VPWidget({
    container: el.value,
    symbol: 'BTC-USDT',
    interval: '60',
    datafeed: myDatafeed,
  });
});

// Same rule as React: the chart does not go away with the component.
onBeforeUnmount(() => widget?.remove());
</script>

<template>
  <div ref="el" style="height: 520px" />
</template>

The live chart, from the same package

The package also carries a second, much smaller chart for places where a price should only look alive: a dashboard card, a ticker, a hero. It needs no datafeed (you pass the points) and shares no code with the full chart, so a page using only one of them downloads only that one. It is imported from three paths in the same checkout:

The live chart's three entries
import { createLiveChart } from '@warburgai/vp-charts/live';        // ESM
import { LiveChart } from '@warburgai/vp-charts/live/react';        // React (your React, 18 or later)
// <script src="/vp-charts/live/vp-live.global.js"></script>        // defines window.VPLive

React is an optional peer of the package: only the React entry imports it, and nothing else in the package does. Mounting, data and options are on The live chart.

The datafeed

The one part with no default. The contract is identical to the embed's, method for method and shape for shape, so Methods you implement is the reference for both: two required methods, the rest optional, and each optional one is a feature that switches itself off when absent rather than an error.

What changes is only how it arrives. An embed sends the calls across a boundary as messages and the loader turns them back into calls on your object; here you pass the object itself. Nothing is serialised, so a value can be anything JavaScript can hold, and an exception in your getBars reaches your own error handler with a stack rather than becoming a refusal on a wire.

Numbers, in the reader’s own format

Every number the chart prints, the price scale, the last-price tag, the crosshair readout, the legend, volumes and study values, follows the locale of the browser it is drawn in: a German reader sees 1.234,56 and a French one 1 234,56 with nothing configured. The reader can pick a fixed format instead under Settings, Scales, Numbers; that choice is remembered per browser and outranks anything the page set, so a control that reverts on reload is not a control you can ship by accident.

A page whose numbers must read one way for everybody pins the pair at construction, and can change it after mount. null returns to the default. A custom_formatters price formatter of your own is never re-punctuated: it owns its output.

Pin a format, or change it after mount
const widget = new VPCharts.widget({
  container: document.getElementById('chart'),
  symbol: 'BTC-USDT',
  interval: '60',
  datafeed: myDatafeed,
  // Absent means the reader's locale. This pair is what "European" means.
  numeric_formatting: { decimal_sign: ',', grouping_separator: '.' },
});

// The same choice after mount; null is back to the default.
widget.setNumberFormat({ decimal: ',', grouping: '.' });
widget.setNumberFormat(null);
PresetReads asThe pair
standard1,234.56decimal . grouping ,
european1.234,56decimal , grouping .
plain1234,56decimal , no grouping
spaced1 234,56decimal , grouping a no-break space
These four are the rows the settings dialog offers, and NUMBER_FORMAT_PRESETS on the package index is the same table, so a picker of your own names the same four rather than a fifth. The format is one per page rather than one per chart: it is a fact about the reader, not the instrument, and two charts on one page disagreeing about how to write a thousand would be the bug.

The mark on the chart

Charts created from this package carry a small badge in the bottom-left corner of the plot, reading Chart by VerticalProp and linking to our site. It collapses out of the way, it never covers the last price, and it does not intercept clicks meant for the chart underneath it.

It is not configurable. There is no option, no CSS hook we support, and no second build without it: the widget attaches the badge itself, and every constructor the package exports is the same branded class. That is the trade the tier is built on. You run the engine under your own domain, your own theme and your own product name, and the one thing that stays is a line saying who wrote the chart, because your users finding us is why this is offered at all.

Worth saying plainly, because you can read the source: you hold the files, so of course you could edit it out. What you cannot do is edit it out and still pass the check on this page. Every file is hashed in release-manifest.json and the manifest is signed, so a modified bundle fails verification in your own pipeline, on the step we are about to ask you to add. Removing the badge is a licence question rather than a technical one, and the answer is in your agreement.

Verifying what you were sent

The package carries release-manifest.json, listing every file with its byte length and SHA-256, and release-signature.json, a detached Ed25519 signature over that manifest. Check it before you serve the files, and check it in the pipeline that deploys them rather than by hand once.

This matters more here than for most dependencies. These files run in the page where your users read prices and, in a trading product, place orders. The manifest is what lets you assert that what you are serving is what was signed, and the check costs nothing per deploy once it is wired.

The key that signs your releases

Every VerticalProp release is signed by one Ed25519 key. Its identifier is the first 24 hex characters of the SHA-256 of the key's SPKI DER, and it is this:

Signing key identifier
5229a6e8addd207d4693cd4e

Pin that value. Signature checking on its own proves the files match the manifest and that the manifest was signed by whoever supplied the key beside it, which is a weaker statement than it looks: anyone who replaces the whole set, files and manifest and signature and key together, produces a package that verifies perfectly against itself. Comparing the keyId in release-signature.json against an identifier you obtained separately, from this page, is what turns the check into a statement about who signed rather than about internal consistency.

You do not have to take our word for the number either. It is derived from the public key, so it can be recomputed from any key you were given, and a key that does not produce it is not ours:

Recompute the identifier from a public key
node -e const {createPublicKey,createHash}=require('crypto'),{readFileSync}=require('fs');
const k=createPublicKey(readFileSync(process.argv[1],'utf8'));
console.log(createHash('sha256').update(k.export({type:'spki',format:'der'})).digest('hex').slice(0,24)); \
  vp-charts-release-public.pem

Wiring it into a deploy

Verify with your own code rather than ours. A verifier we ship inside the package is one more file an attacker who replaced the package would have replaced, so a check that runs it is asking a suspect to vouch for itself. The whole format is a manifest, a detached signature and Ed25519, which node's standard library already has; this is the entire check, with no dependency on anything we sent you:

verify-vp-charts.cjs
const { createHash, createPublicKey, verify } = require('node:crypto');
const { readFileSync, readdirSync } = require('node:fs');
const { join, relative, resolve } = require('node:path');

// Pinned from the VerticalProp documentation, NOT read from the package.
const EXPECTED_KEY_ID = '5229a6e8addd207d4693cd4e';

const dir = resolve(process.argv[2]);
const publicKey = createPublicKey(readFileSync(process.argv[3], 'utf8'));
const die = (m) => { console.error(`REFUSED: ${m}`); process.exit(1); };

const sig = JSON.parse(readFileSync(join(dir, 'release-signature.json'), 'utf8'));
if (sig.algorithm !== 'Ed25519') die(`unexpected algorithm ${sig.algorithm}`);
if (sig.keyId !== EXPECTED_KEY_ID) die(`signed by ${sig.keyId}, expected ${EXPECTED_KEY_ID}`);

const manifestBytes = readFileSync(join(dir, 'release-manifest.json'));
if (createHash('sha256').update(manifestBytes).digest('hex') !== sig.manifestSha256)
  die('manifest digest does not match the signature record');
if (!verify(null, manifestBytes, publicKey, Buffer.from(sig.signature, 'base64')))
  die('signature does not verify against the pinned key');

const manifest = JSON.parse(manifestBytes.toString('utf8'));
// Skip git's own metadata: you are verifying a checkout, and those files
// are not in the manifest. Everything else unexpected must still fail.
const walk = (d) => readdirSync(d, { withFileTypes: true })
  .filter((e) => !(e.isDirectory() && e.name === '.git'))
  .flatMap((e) => (e.isDirectory() ? walk(join(d, e.name)) : [join(d, e.name)]));
const unaccounted = new Set(walk(dir)
  .map((f) => relative(dir, f).replaceAll('\\', '/'))
  .filter((f) => f !== 'release-manifest.json' && f !== 'release-signature.json'));

for (const a of manifest.artifacts) {
  if (!unaccounted.delete(a.path)) die(`manifest lists ${a.path}, which is missing`);
  const bytes = readFileSync(join(dir, a.path));
  if (bytes.length !== a.bytes) die(`${a.path} is the wrong length`);
  if (createHash('sha256').update(bytes).digest('hex') !== a.sha256)
    die(`${a.path} does not match its hash`);
}
// The direction people forget: a file nobody signed, added to a package that
// is otherwise entirely genuine.
if (unaccounted.size > 0) die(`unsigned extra file: ${[...unaccounted][0]}`);

console.log(`OK ${manifest.package}@${manifest.version} (${manifest.artifacts.length} files, build ${manifest.buildFingerprint})`);
The deploy step
node verify-vp-charts.cjs node_modules/@warburgai/vp-charts vp-charts-release-public.pem
The order of those checks is the point of publishing an identifier at all. Everything below the first if proves the package is internally consistent with the key you handed it, which a wholly substituted package also passes: an attacker who replaces the files, the manifest, the signature and the key together produces something that verifies perfectly. Only the comparison against a pinned EXPECTED_KEY_ID says the key is ours.

One smaller thing worth knowing: the identifier is stable across releases, so pinning it once is enough and a new version never asks you to re-approve anything. What the signature proves is integrity, that these are the bytes we published, and not who they were published to. Every licensee receives the same build.

Indicators other people published

Your users can browse the community catalogue and run anything published in it, with no VerticalProp account involved. Three endpoints answer that, all of them public: the catalogue, one listing, and the call that turns a listing into runnable bytes. Proxy them from your own server.

What you must not do is write the loop around them yourself. @warburgai/vp-charts/community exports syncInstalledScripts, and it is where the safety actually lives: it asks the server on every load so a withdrawal reaches a chart that is already open, hashes the bytes on your side so the answer means something, takes the study off the chart the moment a refusal comes back, and distinguishes a refusal from an outage under a bounded grace. Each of those is one line to leave out and none of them fails visibly.

The run loop, wired to your proxy
import { syncInstalledScripts } from '@warburgai/vp-charts/community';

await syncInstalledScripts(widget, {
  // null on failure, NEVER []. An empty list means remove everything; a
  // failed request means change nothing. One 500 must not look like your
  // user uninstalling their whole library.
  fetchInstalled: () => getJson('/vp/installed').catch(() => null),
  fetchSource: (scriptId, hash) => getJson('/vp/source?...').catch(() => null),
  now: Date.now,
  verdicts,            // in memory only: persisting it makes the grace endless
  addToChart: false,   // an install means AVAILABLE; the layout decides what draws
});
Two rules your proxy has to keep. Never cache the source response: we send no-store, and a cache in front of it is a takedown that never arrives. And return null rather than an empty array when a request fails, for the reason in the snippet. Neither is enforceable from our side, and neither shows up in a test on yours.

One honest limitation on this path: there is no pin. Without an account there is no install to record and nobody to ask about an update, so a chart tracks whatever is currently published and an author's new version reaches your users on their next load. Every version they can reach passed the same automatic review.

If your users write indicators

Running code somebody else wrote needs the sandbox, and the sandbox needs sandbox-guest.html deployed on a DIFFERENT SITE from your application, with script_sandbox.frameUrl pointing at it. A different port is not a different site.

The reason is a thread rather than an origin: a frame on the same site shares your main thread, so a script with an endless loop freezes the tab and no watchdog can interrupt it. A separate site gets its own process, which is what makes stopping one possible. Registering an untrusted script without that URL throws rather than quietly falling back to the weaker mode.