LCOV - code coverage report
Current view: top level - dashql-app/src/demos/stock_analysis - synthetic_feed.ts Coverage Total Hit
Test: DashQL 915828b Lines: 100.0 % 118 118
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import type { Tick, TickFeed, TickFeedStatus } from './tick_feed.js';
       2              : 
       3              : /// A symbol of the generator with its price at the start
       4              : interface SyntheticSymbol {
       5              :     symbol: string;
       6              :     price: number;
       7              :     /// The volatility per year, as in the Black-Scholes model
       8              :     volatility: number;
       9              : }
      10              : 
      11              : /// The symbols of the generator, prices roughly as of 2025
      12            1 : export const SYNTHETIC_SYMBOLS: readonly SyntheticSymbol[] = [
      13            1 :     { symbol: 'AAPL', price: 230, volatility: 0.25 },
      14            1 :     { symbol: 'MSFT', price: 420, volatility: 0.22 },
      15            1 :     { symbol: 'NVDA', price: 140, volatility: 0.5 },
      16            1 :     { symbol: 'GOOGL', price: 170, volatility: 0.3 },
      17            1 :     { symbol: 'AMZN', price: 190, volatility: 0.32 },
      18            1 :     { symbol: 'META', price: 560, volatility: 0.38 },
      19            1 :     { symbol: 'TSLA', price: 250, volatility: 0.6 },
      20            1 :     { symbol: 'AVGO', price: 170, volatility: 0.45 },
      21            1 :     { symbol: 'AMD', price: 150, volatility: 0.5 },
      22            1 :     { symbol: 'NFLX', price: 700, volatility: 0.35 },
      23            1 :     { symbol: 'CRM', price: 290, volatility: 0.33 },
      24            1 :     { symbol: 'ORCL', price: 170, volatility: 0.3 },
      25            1 :     { symbol: 'ADBE', price: 500, volatility: 0.3 },
      26            1 :     { symbol: 'INTC', price: 22, volatility: 0.45 },
      27            1 :     { symbol: 'QCOM', price: 160, volatility: 0.35 },
      28            1 :     { symbol: 'JPM', price: 220, volatility: 0.22 },
      29            1 :     { symbol: 'V', price: 290, volatility: 0.18 },
      30            1 :     { symbol: 'MA', price: 500, volatility: 0.2 },
      31            1 :     { symbol: 'WMT', price: 80, volatility: 0.16 },
      32            1 :     { symbol: 'KO', price: 65, volatility: 0.14 },
      33            1 :     { symbol: 'PEP', price: 165, volatility: 0.15 },
      34            1 :     { symbol: 'DIS', price: 95, volatility: 0.28 },
      35            1 :     { symbol: 'NKE', price: 80, volatility: 0.32 },
      36            1 :     { symbol: 'XOM', price: 115, volatility: 0.24 },
      37            1 : ];
      38              : 
      39              : /// The seconds of a trading year, the unit of the volatility
      40            1 : const SECONDS_PER_YEAR = 252 * 6.5 * 3600;
      41              : /// The volatility is exaggerated, a real one barely moves the board within minutes
      42            1 : const VOLATILITY_SCALE = 8;
      43              : 
      44              : /// A pseudo random generator of numbers in [0, 1), the same for the same seed (mulberry32)
      45            1 : export function seededRandom(seed: number): () => number {
      46            1 :     let state = seed >>> 0;
      47            1 :     return () => {
      48            1 :         state = (state + 0x6d2b79f5) >>> 0;
      49            1 :         let t = state;
      50            1 :         t = Math.imul(t ^ (t >>> 15), t | 1);
      51            1 :         t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
      52            1 :         return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
      53            1 :     };
      54            1 : }
      55              : 
      56              : /// The options of the generator
      57              : export interface SyntheticFeedOptions {
      58              :     /// The seed of the random numbers
      59              :     seed?: number;
      60              :     /// The ticks per second
      61              :     ticksPerSecond?: number;
      62              :     /// The time between two deliveries
      63              :     emitIntervalMs?: number;
      64              :     /// The time that the first delivery reaches back, so that the charts start with a history
      65              :     backfillMs?: number;
      66              : }
      67              : 
      68              : /// Trades of made-up prices: a geometric random walk per symbol, with more trades for the first symbols
      69            1 : export class SyntheticFeed implements TickFeed {
      70              :     /// The random numbers
      71            1 :     private readonly random: () => number;
      72              :     /// The current price per symbol
      73            1 :     private readonly prices: number[];
      74              :     /// The time of the latest tick per symbol
      75            1 :     private readonly symbolTs: (number | null)[];
      76              :     /// The cumulative weights of the symbols, the first symbols trade more often
      77            1 :     private readonly weights: number[];
      78              :     /// The time of the latest tick
      79            1 :     private lastTs: number | null = null;
      80              :     /// The ticks that are due but not delivered yet, as a fraction
      81            1 :     private carry = 0;
      82              :     /// The timer of the deliveries
      83            1 :     private timer: ReturnType<typeof setInterval> | null = null;
      84              : 
      85              :     /// The ticks per second, a change applies to the next delivery
      86            1 :     ticksPerSecond: number;
      87            1 :     private readonly emitIntervalMs: number;
      88            1 :     private readonly backfillMs: number;
      89              : 
      90            1 :     constructor(options: SyntheticFeedOptions = {}) {
      91            1 :         this.random = seededRandom(options.seed ?? 42);
      92            1 :         this.ticksPerSecond = options.ticksPerSecond ?? 1000;
      93            1 :         this.emitIntervalMs = options.emitIntervalMs ?? 50;
      94            1 :         this.backfillMs = options.backfillMs ?? 5 * 60 * 1000;
      95            1 :         this.prices = SYNTHETIC_SYMBOLS.map(s => s.price);
      96            1 :         this.symbolTs = SYNTHETIC_SYMBOLS.map(() => null);
      97            1 :         let total = 0;
      98            1 :         this.weights = SYNTHETIC_SYMBOLS.map((_, i) => (total += 1 / (i + 1)));
      99            1 :         this.weights = this.weights.map(w => w / total);
     100            1 :     }
     101              : 
     102              :     /// A normally distributed random number (Box-Muller)
     103            1 :     private normal(): number {
     104            1 :         const u = 1 - this.random();
     105            1 :         const v = this.random();
     106            1 :         return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
     107            1 :     }
     108              : 
     109              :     /// The index of a random symbol by the weights
     110            1 :     private pickSymbol(): number {
     111            1 :         const r = this.random();
     112            1 :         const index = this.weights.findIndex(w => r < w);
     113            1 :         return index < 0 ? this.weights.length - 1 : index;
     114            1 :     }
     115              : 
     116              :     /// Generate the ticks from the latest one up to a time, spread evenly over that time
     117            1 :     generate(count: number, untilMs: number): Tick[] {
     118            1 :         const from = this.lastTs ?? untilMs;
     119            1 :         const ticks: Tick[] = [];
     120            1 :         for (let i = 0; i < count; ++i) {
     121            1 :             const ts = count === 1 ? untilMs : from + ((untilMs - from) * (i + 1)) / count;
     122            1 :             const s = this.pickSymbol();
     123            1 :             const dtSeconds = Math.max((ts - (this.symbolTs[s] ?? ts)) / 1000, 0.001);
     124            1 :             this.symbolTs[s] = ts;
     125            1 :             const sigma = SYNTHETIC_SYMBOLS[s].volatility * VOLATILITY_SCALE * Math.sqrt(dtSeconds / SECONDS_PER_YEAR);
     126            1 :             const price = Math.round(this.prices[s] * Math.exp(sigma * this.normal()) * 100) / 100;
     127            1 :             this.prices[s] = Math.max(price, 0.01);
     128            1 :             const halfSpread = Math.max(0.01, Math.round(this.prices[s] * 0.0001 * (1 + this.random()) * 100) / 100);
     129            1 :             ticks.push({
     130            1 :                 symbol: SYNTHETIC_SYMBOLS[s].symbol,
     131            1 :                 ts: Math.round(ts),
     132            1 :                 price: this.prices[s],
     133            1 :                 size: Math.max(1, Math.round(Math.exp(3 + 1.2 * this.normal()))),
     134            1 :                 bid: Math.round((this.prices[s] - halfSpread) * 100) / 100,
     135            1 :                 ask: Math.round((this.prices[s] + halfSpread) * 100) / 100,
     136            1 :             });
     137            1 :             this.lastTs = ts;
     138            1 :         }
     139            1 :         return ticks;
     140            1 :     }
     141              : 
     142            1 :     start(emit: (ticks: Tick[]) => void, onStatus: (status: TickFeedStatus) => void): void {
     143            1 :         this.stop();
     144            1 :         const now = Date.now();
     145            1 :         if (this.lastTs == null && this.backfillMs > 0) {
     146              :             // The history at a tenth of the rate, so that it does not take long to insert
     147            1 :             this.lastTs = now - this.backfillMs;
     148            1 :             emit(this.generate(Math.round((this.ticksPerSecond / 10) * (this.backfillMs / 1000)), now));
     149            1 :         }
     150            1 :         this.lastTs = now;
     151            1 :         onStatus({ state: 'live' });
     152            1 :         let last = now;
     153            1 :         this.timer = setInterval(() => {
     154            1 :             const at = Date.now();
     155            1 :             this.carry += (this.ticksPerSecond * (at - last)) / 1000;
     156            1 :             last = at;
     157            1 :             const count = Math.floor(this.carry);
     158            1 :             this.carry -= count;
     159            1 :             if (count > 0) emit(this.generate(count, at));
     160            1 :         }, this.emitIntervalMs);
     161            1 :     }
     162              : 
     163            1 :     stop(): void {
     164            1 :         if (this.timer != null) clearInterval(this.timer);
     165            1 :         this.timer = null;
     166            1 :     }
     167            1 : }
        

Generated by: LCOV version 2.0-1