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 : }
|