Files
investor-flow/app/src/lib/volumeProfile.ts
T
Investor Flow Build ac94acf9e3
CI / Test & Type-Check (push) Canceled after 0s
feat: dealer flow, mirror portfolio (M21), options convexity, FINRA short interest, alert producers, vendor gate
Snapshot of in-progress module work across multiple slices:

- Dealer Flow: dealerExposureEngine, dealerMapService, dealerMapExplain,
  dealerMapIntegrity, dealerMapReplay, dealerStudyEngine, hanStyleLevels
- Mirror Portfolio (M21): fundRepository, captureIngest, mirrorAlertProducers,
  fund holdings strip, live book, position capture ingest
- Options: BSM, NormalizedOptionSurface types, OptionsChainRouter,
  ConvexityGate, option legs panel
- Alert producers: vixLevel, rotation, thesis, unlock, portfolioRisk,
  mirror (fund_capture, fund_13f, mirror_diff)
- FINRA short interest adapter + queue integration
- SEC company tickers adapter + ingest (symbol search index seed)
- Vendor gate (rate-limit-first data plane, ADR-0009)
- CUSIP registry, reverse 13F refresh, stock float service
- LRU cache, portfolio backtest engine
- Frontend: dealer-flow, funds, journal, lab, monitor, plan, portfolio,
  reports, screener, strategies, theses, guided-start, exits, more pages
- Volume profile, workspace profile, visibility-aware poll
- ADRs 0010 (mirror math not advice), 0011 (symbol search index)
- VENDOR_INTEGRATIONS.md, END_USER_TEST.md
- .gitignore: exclude DBs, .DS_Store, local config, agent scratch
2026-08-10 13:36:26 -04:00

138 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Equity volume-by-price profile from OHLC bars (not options OI).
* Uniform volume split across bins that a bar's low–high spans.
*/
export interface VolumeProfileCandle {
h: number;
l: number;
c?: number;
v: number;
}
export interface VolumeProfileBin {
priceLo: number;
priceHi: number;
mid: number;
volume: number;
}
export interface VolumeProfileResult {
bins: VolumeProfileBin[];
/** Mid-price of the highest-volume bin. */
pocMid: number | null;
/** Value-area high / low (mids of outer bins in the value area). */
vah: number | null;
val: number | null;
totalVolume: number;
}
export interface BuildVolumeProfileOpts {
/** Number of price bins. Default 32. */
bins?: number;
/** Fraction of volume for value area (e.g. 0.7). Default 0.7. */
valueAreaPct?: number;
}
/**
* Build a volume profile from candles in the visible range.
* Empty or zero-volume inputs return empty bins.
*/
export function buildVolumeProfile(
candles: VolumeProfileCandle[],
opts: BuildVolumeProfileOpts = {},
): VolumeProfileResult {
const binCount = Math.max(2, Math.min(80, Math.floor(opts.bins ?? 32)));
const valueAreaPct = Math.min(0.99, Math.max(0.5, opts.valueAreaPct ?? 0.7));
const usable = candles.filter(
(c) =>
Number.isFinite(c.h) &&
Number.isFinite(c.l) &&
Number.isFinite(c.v) &&
c.v > 0 &&
c.h >= c.l,
);
if (usable.length === 0) {
return { bins: [], pocMid: null, vah: null, val: null, totalVolume: 0 };
}
let priceMin = Infinity;
let priceMax = -Infinity;
let totalVolume = 0;
for (const c of usable) {
if (c.l < priceMin) priceMin = c.l;
if (c.h > priceMax) priceMax = c.h;
totalVolume += c.v;
}
// Flat range: single effective level
if (!(priceMax > priceMin)) {
const mid = priceMin;
const bins: VolumeProfileBin[] = [
{ priceLo: mid, priceHi: mid, mid, volume: totalVolume },
];
return { bins, pocMid: mid, vah: mid, val: mid, totalVolume };
}
const width = (priceMax - priceMin) / binCount;
const volumes = new Array<number>(binCount).fill(0);
for (const c of usable) {
const lo = c.l;
const hi = c.h;
// Inclusive bins from low to high
let i0 = Math.floor((lo - priceMin) / width);
let i1 = Math.floor((hi - priceMin) / width);
if (i0 < 0) i0 = 0;
if (i1 >= binCount) i1 = binCount - 1;
if (i1 < i0) i1 = i0;
const n = i1 - i0 + 1;
const share = c.v / n;
for (let i = i0; i <= i1; i++) volumes[i] += share;
}
const bins: VolumeProfileBin[] = volumes.map((volume, i) => {
const priceLo = priceMin + i * width;
const priceHi = i === binCount - 1 ? priceMax : priceMin + (i + 1) * width;
return {
priceLo,
priceHi,
mid: (priceLo + priceHi) / 2,
volume,
};
});
// POC = max volume bin
let pocIdx = 0;
for (let i = 1; i < bins.length; i++) {
if (bins[i].volume > bins[pocIdx].volume) pocIdx = i;
}
// Value area: expand from POC until we cover valueAreaPct of total volume
const target = totalVolume * valueAreaPct;
let lo = pocIdx;
let hi = pocIdx;
let covered = bins[pocIdx].volume;
while (covered < target && (lo > 0 || hi < bins.length - 1)) {
const nextLo = lo > 0 ? bins[lo - 1].volume : -1;
const nextHi = hi < bins.length - 1 ? bins[hi + 1].volume : -1;
if (nextHi >= nextLo) {
hi++;
covered += bins[hi].volume;
} else {
lo--;
covered += bins[lo].volume;
}
}
return {
bins,
pocMid: bins[pocIdx].mid,
vah: bins[hi].priceHi,
val: bins[lo].priceLo,
totalVolume,
};
}