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.
git submodule add https://github.com/WarburgAI/vertical-ultracharts-library.git vendor/vp-charts
git -C vendor/vp-charts checkout vp-charts-v.5.0npm install github:WarburgAI/vertical-ultracharts-library#vp-charts-v.5.0Pin 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.
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.
| File | What it is |
|---|---|
compat.js | The 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.js | The same chart as ONE file that defines window.VPCharts. No bundler, no module loading, nothing else to fetch. |
vp-charts.css | The stylesheet. Required: without it the chart renders unstyled chrome. |
compat.d.ts | TypeScript declarations for the whole surface, plus compat.global.d.ts for the script-tag build. |
sandbox-guest.html | Deploy 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.js | The 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.json | Every option and method with its status, generated from the same build. |
release-manifest.json | Every 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.
| Build | Use it when | Transfer |
|---|---|---|
compat.js | You have a bundler, or you can serve modules. The default choice. | ~112 KB gzip on first load, the rest on demand |
compat.global.js | No 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.css | Always, alongside either. | ~15 KB gzip |
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.
<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.
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 }} />;
}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.
<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:
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.VPLiveReact 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.
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);| Preset | Reads as | The pair |
|---|---|---|
standard | 1,234.56 | decimal . grouping , |
european | 1.234,56 | decimal , grouping . |
plain | 1234,56 | decimal , no grouping |
spaced | 1 234,56 | decimal , grouping a no-break space |
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.
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.
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:
5229a6e8addd207d4693cd4ePin 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:
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.pemWiring 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:
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})`);node verify-vp-charts.cjs node_modules/@warburgai/vp-charts vp-charts-release-public.pemif 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.
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
});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.