stock-market-gen 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/dist/generator.cjs +230 -0
- package/dist/html.cjs +154 -0
- package/dist/index.cjs +9 -0
- package/dist/interval.cjs +40 -0
- package/dist/prng.cjs +73 -0
- package/dist/svg.cjs +323 -0
- package/dist/symbols.cjs +33 -0
- package/package.json +42 -0
- package/src/generator.js +228 -0
- package/src/html.js +152 -0
- package/src/index.js +13 -0
- package/src/interval.js +38 -0
- package/src/prng.js +71 -0
- package/src/svg.js +321 -0
- package/src/symbols.js +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# stock-market-gen
|
|
2
|
+
|
|
3
|
+
Generate realistic fake stock market data plus SVG charts and standalone HTML pages. Pure JS, zero dependencies, works in Node and the browser.
|
|
4
|
+
|
|
5
|
+
A modern, more capable replacement for the old `fake-stock-market-generator` package.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install stock-market-gen
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { generateStock, renderLineChart } from 'stock-market-gen';
|
|
17
|
+
|
|
18
|
+
const stock = generateStock({ bars: 100, interval: '1d', seed: 'demo' });
|
|
19
|
+
const svg = renderLineChart(stock);
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
CommonJS works too:
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
const { generateStock, renderLineChart } = require('stock-market-gen');
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## What you can generate
|
|
29
|
+
|
|
30
|
+
- single stocks or whole markets
|
|
31
|
+
- OHLCV bars (open, high, low, close, volume)
|
|
32
|
+
- pick any time interval — `"1m"`, `"5m"`, `"1h"`, `"1d"`, `"1w"`, `"1mo"`, `"1y"` or raw milliseconds
|
|
33
|
+
- override anything: symbol, name, sector, start price, drift, volatility, start date
|
|
34
|
+
- pass a `stocks` array to define each company yourself
|
|
35
|
+
- reproducible output via a seed
|
|
36
|
+
|
|
37
|
+
## Chart types
|
|
38
|
+
|
|
39
|
+
- `line` — close price line
|
|
40
|
+
- `area` — line plus filled area
|
|
41
|
+
- `bar` — OHLC bar (tick-left, tick-right)
|
|
42
|
+
- `candlestick` — classic candles, green up / red down
|
|
43
|
+
|
|
44
|
+
All renderers return a complete SVG string. Save it to a file, drop it into HTML, or pipe it anywhere.
|
|
45
|
+
|
|
46
|
+
## Customize anything
|
|
47
|
+
|
|
48
|
+
Every field is optional. Set the ones you care about, the rest are generated for you. The package only invents the symbol and the price series — `name`, `sector`, `startDate`, etc. are yours to provide.
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
import { generateStock } from 'stock-market-gen';
|
|
52
|
+
|
|
53
|
+
const stock = generateStock({
|
|
54
|
+
symbol: 'YOUR_SYMBOL',
|
|
55
|
+
name: 'Your Company Name',
|
|
56
|
+
sector: 'Your Sector',
|
|
57
|
+
startPrice: 180,
|
|
58
|
+
drift: 0.12, // +12% per year
|
|
59
|
+
volatility: 0.28, // 28% per year
|
|
60
|
+
bars: 60,
|
|
61
|
+
interval: '1w', // weekly bars
|
|
62
|
+
startDate: '2024-01-01',
|
|
63
|
+
seed: 'anything'
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Same idea for a market — pass a `stocks` array and pin whatever you want per company:
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
import { generateMarket } from 'stock-market-gen';
|
|
71
|
+
|
|
72
|
+
const market = generateMarket({
|
|
73
|
+
bars: 90,
|
|
74
|
+
interval: '1mo',
|
|
75
|
+
startDate: '2023-01-01',
|
|
76
|
+
seed: 'demo',
|
|
77
|
+
stocks: [
|
|
78
|
+
{ symbol: 'YOUR1', name: 'Your Company 1', sector: 'Your Sector' },
|
|
79
|
+
{ symbol: 'YOUR2', name: 'Your Company 2', sector: 'Your Sector', volatility: 0.45 },
|
|
80
|
+
{ symbol: 'YOUR3' } // name and sector left empty
|
|
81
|
+
]
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or skip the array entirely and just get random tickers with no company info:
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
const market = generateMarket({ count: 8, bars: 90, seed: 'demo' });
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## API
|
|
92
|
+
|
|
93
|
+
### `generateStock(options) -> Stock`
|
|
94
|
+
|
|
95
|
+
| Option | Type | Default | Notes |
|
|
96
|
+
|---------------|----------------------------|------------------|-------|
|
|
97
|
+
| `symbol` | `string` | random 2-5 chars | |
|
|
98
|
+
| `name` | `string` | none (you supply it) | optional company name |
|
|
99
|
+
| `sector` | `string` | none (you supply it) | optional sector label |
|
|
100
|
+
| `startPrice` | `number` | 50–500 | |
|
|
101
|
+
| `drift` | `number` | random | annualised, e.g. `0.05` = +5%/yr |
|
|
102
|
+
| `volatility` | `number` | random | annualised, e.g. `0.3` = 30%/yr |
|
|
103
|
+
| `bars` | `number` | `100` | positive integer |
|
|
104
|
+
| `interval` | `number \| string` | `"1d"` | ms or `"1m"`/`"1h"`/`"1d"`/`"1w"`/`"1mo"`/`"1y"` |
|
|
105
|
+
| `startDate` | `Date \| number \| string` | `now - bars*interval` | first bar timestamp |
|
|
106
|
+
| `seed` | `number \| string` | random | reproducible output |
|
|
107
|
+
|
|
108
|
+
### `generateMarket({ count, stocks, ...stockOptions }) -> Stock[]`
|
|
109
|
+
|
|
110
|
+
Generate several unique tickers in one call. Pass `count` for an auto-generated market (random symbols, no name or sector), or `stocks` to define each company yourself. Any other option is applied as a shared default.
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
// Auto-generated, 8 random tickers, all daily, same seed -> same market
|
|
114
|
+
const market = generateMarket({ count: 8, bars: 90, interval: '1d', seed: 'demo' });
|
|
115
|
+
|
|
116
|
+
// Hand-picked companies, with shared defaults
|
|
117
|
+
const market = generateMarket({
|
|
118
|
+
bars: 60,
|
|
119
|
+
interval: '1w',
|
|
120
|
+
seed: 'custom',
|
|
121
|
+
stocks: [
|
|
122
|
+
{ symbol: 'YOUR1', name: 'Your Company 1', sector: 'Your Sector', startPrice: 180 },
|
|
123
|
+
{ symbol: 'YOUR2', name: 'Your Company 2', sector: 'Your Sector', volatility: 0.45 },
|
|
124
|
+
{ symbol: 'YOUR3' }
|
|
125
|
+
]
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### `renderChart(stock, type, options)`
|
|
130
|
+
|
|
131
|
+
`type` is `"line"`, `"area"`, `"bar"` or `"candlestick"`. Or call the dedicated renderers: `renderLineChart`, `renderAreaChart`, `renderBarChart`, `renderCandlestickChart`.
|
|
132
|
+
|
|
133
|
+
Chart options:
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
{
|
|
137
|
+
width: 800,
|
|
138
|
+
height: 400,
|
|
139
|
+
theme: 'light', // 'light' | 'dark'
|
|
140
|
+
title: 'AAPL — daily',
|
|
141
|
+
showGrid: true,
|
|
142
|
+
showAxes: true,
|
|
143
|
+
padding: { top: 24, right: 16, bottom: 36, left: 56 },
|
|
144
|
+
colors: { line: '#2563eb' } // override any single color
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### `renderHtmlPage(marketOrStock, options) -> string`
|
|
149
|
+
|
|
150
|
+
Builds a self-contained HTML page with embedded SVG charts. Pass an array (whole market) or a single stock.
|
|
151
|
+
|
|
152
|
+
```js
|
|
153
|
+
import { generateMarket, renderHtmlPage } from 'stock-market-gen';
|
|
154
|
+
import { writeFileSync } from 'node:fs';
|
|
155
|
+
|
|
156
|
+
const market = generateMarket({ count: 8, bars: 90, seed: 'page' });
|
|
157
|
+
writeFileSync('market.html', renderHtmlPage(market, { theme: 'dark', chartType: 'area' }));
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Examples
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
npm run example # writes out/line.svg and out/candle.svg
|
|
164
|
+
npm run example:page # writes out/market.html
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Price data generation.
|
|
2
|
+
//
|
|
3
|
+
// Uses Geometric Brownian Motion so prices stay positive and the variance
|
|
4
|
+
// scales with the current price — closer to how real markets behave than a
|
|
5
|
+
// plain random walk.
|
|
6
|
+
//
|
|
7
|
+
// S_{t+1} = S_t * exp((mu - sigma^2 / 2) * dt + sigma * sqrt(dt) * Z)
|
|
8
|
+
//
|
|
9
|
+
// Each bar then gets an open/high/low/close and a synthetic volume.
|
|
10
|
+
|
|
11
|
+
const { createRng } = require('./prng.cjs');
|
|
12
|
+
const { makeSymbol } = require('./symbols.cjs');
|
|
13
|
+
const { parseInterval } = require('./interval.cjs');
|
|
14
|
+
|
|
15
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
const YEAR_MS = 365 * DAY_MS;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} Bar
|
|
20
|
+
* @property {number} time - Unix epoch in milliseconds
|
|
21
|
+
* @property {string} date - ISO 8601 timestamp
|
|
22
|
+
* @property {number} open
|
|
23
|
+
* @property {number} high
|
|
24
|
+
* @property {number} low
|
|
25
|
+
* @property {number} close
|
|
26
|
+
* @property {number} volume
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} Stock
|
|
31
|
+
* @property {string} symbol
|
|
32
|
+
* @property {string} name
|
|
33
|
+
* @property {string} sector
|
|
34
|
+
* @property {number} startPrice
|
|
35
|
+
* @property {number} interval - milliseconds between bars
|
|
36
|
+
* @property {Bar[]} bars
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {Object} GenerateStockOptions
|
|
41
|
+
* @property {string} [symbol] - Override the generated symbol
|
|
42
|
+
* @property {string} [name] - Override the generated company name
|
|
43
|
+
* @property {string} [sector] - Override the generated sector
|
|
44
|
+
* @property {number} [startPrice] - First bar's open price (default: 50-500)
|
|
45
|
+
* @property {number} [drift] - Annualised drift, e.g. 0.05 = +5%/year
|
|
46
|
+
* @property {number} [volatility] - Annualised volatility, e.g. 0.3 = 30%/year
|
|
47
|
+
* @property {number} [bars] - Number of bars to generate (default: 100)
|
|
48
|
+
* @property {number|string} [interval] - Bar size; ms or "1m"/"1h"/"1d" (default: "1d")
|
|
49
|
+
* @property {Date|number|string} [startDate] - First bar timestamp (default: now - bars*interval)
|
|
50
|
+
* @property {number|string} [seed] - Reproducible output
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
const DEFAULTS = {
|
|
54
|
+
bars: 100,
|
|
55
|
+
interval: '1d'
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function round2(n) {
|
|
59
|
+
return Math.round(n * 100) / 100;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function typeOf(v) {
|
|
63
|
+
if (v === null) return 'null';
|
|
64
|
+
if (Array.isArray(v)) return 'array';
|
|
65
|
+
return typeof v;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Generate price data for a single stock.
|
|
70
|
+
* @param {GenerateStockOptions} [options]
|
|
71
|
+
* @returns {Stock}
|
|
72
|
+
*/
|
|
73
|
+
function generateStock(options = {}) {
|
|
74
|
+
const opts = { ...DEFAULTS, ...options };
|
|
75
|
+
const rng = createRng(opts.seed);
|
|
76
|
+
|
|
77
|
+
if (!Number.isInteger(opts.bars) || opts.bars < 1) {
|
|
78
|
+
throw new Error(`"bars" must be a positive integer, got ${opts.bars}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Optional fields: only validated when the caller actually provided them.
|
|
82
|
+
if (opts.symbol !== undefined && (typeof opts.symbol !== 'string' || opts.symbol.length === 0)) {
|
|
83
|
+
throw new Error(`"symbol" must be a non-empty string, got ${typeOf(opts.symbol)}`);
|
|
84
|
+
}
|
|
85
|
+
if (opts.name !== undefined && opts.name !== null && typeof opts.name !== 'string') {
|
|
86
|
+
throw new Error(`"name" must be a string, got ${typeOf(opts.name)}`);
|
|
87
|
+
}
|
|
88
|
+
if (opts.sector !== undefined && opts.sector !== null && typeof opts.sector !== 'string') {
|
|
89
|
+
throw new Error(`"sector" must be a string, got ${typeOf(opts.sector)}`);
|
|
90
|
+
}
|
|
91
|
+
if (opts.startPrice !== undefined && (!Number.isFinite(opts.startPrice) || opts.startPrice <= 0)) {
|
|
92
|
+
throw new Error(`"startPrice" must be a positive number, got ${opts.startPrice}`);
|
|
93
|
+
}
|
|
94
|
+
if (opts.drift !== undefined && !Number.isFinite(opts.drift)) {
|
|
95
|
+
throw new Error(`"drift" must be a finite number, got ${opts.drift}`);
|
|
96
|
+
}
|
|
97
|
+
if (opts.volatility !== undefined && (!Number.isFinite(opts.volatility) || opts.volatility < 0)) {
|
|
98
|
+
throw new Error(`"volatility" must be a non-negative number, got ${opts.volatility}`);
|
|
99
|
+
}
|
|
100
|
+
if (opts.startDate !== undefined) {
|
|
101
|
+
const t = new Date(opts.startDate).getTime();
|
|
102
|
+
if (!Number.isFinite(t)) {
|
|
103
|
+
throw new Error(`"startDate" is not a valid date: ${opts.startDate}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const intervalMs = parseInterval(opts.interval);
|
|
108
|
+
|
|
109
|
+
const symbol = opts.symbol ?? makeSymbol(rng);
|
|
110
|
+
const name = opts.name ?? null;
|
|
111
|
+
const sector = opts.sector ?? null;
|
|
112
|
+
|
|
113
|
+
const startPrice = opts.startPrice ?? round2(rng.next() * 450 + 50); // 50..500
|
|
114
|
+
// Drift roughly in [-15%, +25%] per year, vol in [10%, 60%] per year.
|
|
115
|
+
const drift = opts.drift ?? rng.next() * 0.4 - 0.15;
|
|
116
|
+
const volatility = opts.volatility ?? rng.next() * 0.5 + 0.1;
|
|
117
|
+
|
|
118
|
+
const startTime =
|
|
119
|
+
opts.startDate !== undefined
|
|
120
|
+
? new Date(opts.startDate).getTime()
|
|
121
|
+
: Date.now() - opts.bars * intervalMs;
|
|
122
|
+
|
|
123
|
+
const dt = intervalMs / YEAR_MS;
|
|
124
|
+
const drift2 = (drift - (volatility * volatility) / 2) * dt;
|
|
125
|
+
const diffusion = volatility * Math.sqrt(dt);
|
|
126
|
+
|
|
127
|
+
const bars = new Array(opts.bars);
|
|
128
|
+
let price = startPrice;
|
|
129
|
+
|
|
130
|
+
for (let i = 0; i < opts.bars; i++) {
|
|
131
|
+
const open = price;
|
|
132
|
+
|
|
133
|
+
// Step the close using GBM
|
|
134
|
+
let close = open * Math.exp(drift2 + diffusion * rng.gauss());
|
|
135
|
+
if (close < 0.01) close = 0.01;
|
|
136
|
+
|
|
137
|
+
// High/low wick around the open-close range, scaled by volatility
|
|
138
|
+
const wick = Math.abs(open - close) + open * diffusion * (0.5 + rng.next());
|
|
139
|
+
const high = Math.max(open, close) + wick * rng.next();
|
|
140
|
+
const low = Math.max(0.01, Math.min(open, close) - wick * rng.next());
|
|
141
|
+
|
|
142
|
+
// Volume: log-normal-ish around 1M, with a kick on bigger moves
|
|
143
|
+
const move = Math.abs(close - open) / open;
|
|
144
|
+
const volume = Math.max(
|
|
145
|
+
1,
|
|
146
|
+
Math.round((500_000 + rng.next() * 1_500_000) * (1 + move * 20))
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const time = startTime + i * intervalMs;
|
|
150
|
+
bars[i] = {
|
|
151
|
+
time,
|
|
152
|
+
date: new Date(time).toISOString(),
|
|
153
|
+
open: round2(open),
|
|
154
|
+
high: round2(high),
|
|
155
|
+
low: round2(low),
|
|
156
|
+
close: round2(close),
|
|
157
|
+
volume
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
price = close;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
symbol,
|
|
165
|
+
name,
|
|
166
|
+
sector,
|
|
167
|
+
startPrice: round2(startPrice),
|
|
168
|
+
interval: intervalMs,
|
|
169
|
+
bars
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @typedef {Object} GenerateMarketOptions
|
|
175
|
+
* @property {number} [count] - Number of stocks (default: 5)
|
|
176
|
+
* @property {GenerateStockOptions[]} [stocks] - Per-stock overrides. When
|
|
177
|
+
* provided, the market size matches this array's length and each entry can
|
|
178
|
+
* pin its own `symbol`, `name`, `sector`, `startPrice`, etc.
|
|
179
|
+
*
|
|
180
|
+
* Any other field is treated as a shared default applied to every stock.
|
|
181
|
+
*/
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Generate a whole market of stocks. Symbols stay unique within the market.
|
|
185
|
+
* @param {GenerateMarketOptions} [options]
|
|
186
|
+
* @returns {Stock[]}
|
|
187
|
+
*/
|
|
188
|
+
function generateMarket(options = {}) {
|
|
189
|
+
const { count, stocks, seed, ...sharedOpts } = options;
|
|
190
|
+
|
|
191
|
+
// Decide how many stocks we're producing
|
|
192
|
+
let perStock;
|
|
193
|
+
if (Array.isArray(stocks)) {
|
|
194
|
+
if (stocks.length < 1) {
|
|
195
|
+
throw new Error('"stocks" array must contain at least one entry');
|
|
196
|
+
}
|
|
197
|
+
perStock = stocks;
|
|
198
|
+
} else {
|
|
199
|
+
const n = count ?? 5;
|
|
200
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
201
|
+
throw new Error(`"count" must be a positive integer, got ${n}`);
|
|
202
|
+
}
|
|
203
|
+
perStock = new Array(n).fill(null).map(() => ({}));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// A master RNG drives per-stock seeds so the whole market is reproducible
|
|
207
|
+
// from a single user seed.
|
|
208
|
+
const master = createRng(seed);
|
|
209
|
+
const used = new Set();
|
|
210
|
+
|
|
211
|
+
// Reserve any symbols the caller pinned, so generated ones don't collide.
|
|
212
|
+
for (const entry of perStock) {
|
|
213
|
+
if (entry && typeof entry.symbol === 'string') used.add(entry.symbol);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return perStock.map((entry) => {
|
|
217
|
+
const stockSeed = master.int(0, 0xFFFFFFFF);
|
|
218
|
+
const merged = { ...sharedOpts, ...entry, seed: entry?.seed ?? stockSeed };
|
|
219
|
+
|
|
220
|
+
if (typeof merged.symbol !== 'string') {
|
|
221
|
+
// Build a deterministic rng just to pick a unique symbol up front
|
|
222
|
+
const rng = createRng(merged.seed);
|
|
223
|
+
merged.symbol = makeSymbol(rng, used);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return generateStock(merged);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = { generateStock, generateMarket };
|
package/dist/html.cjs
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Standalone HTML page renderer. Builds a self-contained document with the
|
|
2
|
+
// market summary and embedded SVG charts. No external assets, no JS.
|
|
3
|
+
|
|
4
|
+
const { renderChart } = require('./svg.cjs');
|
|
5
|
+
|
|
6
|
+
function escapeHtml(str) {
|
|
7
|
+
return String(str)
|
|
8
|
+
.replace(/&/g, '&')
|
|
9
|
+
.replace(/</g, '<')
|
|
10
|
+
.replace(/>/g, '>')
|
|
11
|
+
.replace(/"/g, '"')
|
|
12
|
+
.replace(/'/g, ''');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function changePct(stock) {
|
|
16
|
+
const first = stock.bars[0].open;
|
|
17
|
+
const last = stock.bars[stock.bars.length - 1].close;
|
|
18
|
+
return ((last - first) / first) * 100;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildStyles(theme) {
|
|
22
|
+
const dark = theme === 'dark';
|
|
23
|
+
return `
|
|
24
|
+
:root {
|
|
25
|
+
--bg: ${dark ? '#0b1220' : '#f9fafb'};
|
|
26
|
+
--card: ${dark ? '#111827' : '#ffffff'};
|
|
27
|
+
--border: ${dark ? '#1f2937' : '#e5e7eb'};
|
|
28
|
+
--text: ${dark ? '#e5e7eb' : '#111827'};
|
|
29
|
+
--muted: ${dark ? '#9ca3af' : '#6b7280'};
|
|
30
|
+
--up: ${dark ? '#22c55e' : '#16a34a'};
|
|
31
|
+
--down: ${dark ? '#ef4444' : '#dc2626'};
|
|
32
|
+
}
|
|
33
|
+
* { box-sizing: border-box; }
|
|
34
|
+
body {
|
|
35
|
+
margin: 0;
|
|
36
|
+
background: var(--bg);
|
|
37
|
+
color: var(--text);
|
|
38
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
39
|
+
padding: 24px;
|
|
40
|
+
}
|
|
41
|
+
h1 { margin: 0 0 4px; font-size: 22px; }
|
|
42
|
+
p.lead { margin: 0 0 24px; color: var(--muted); }
|
|
43
|
+
.grid {
|
|
44
|
+
display: grid;
|
|
45
|
+
gap: 16px;
|
|
46
|
+
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
|
47
|
+
}
|
|
48
|
+
.card {
|
|
49
|
+
background: var(--card);
|
|
50
|
+
border: 1px solid var(--border);
|
|
51
|
+
border-radius: 10px;
|
|
52
|
+
padding: 16px;
|
|
53
|
+
}
|
|
54
|
+
.header {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: baseline;
|
|
57
|
+
justify-content: space-between;
|
|
58
|
+
margin-bottom: 8px;
|
|
59
|
+
gap: 12px;
|
|
60
|
+
}
|
|
61
|
+
.symbol { font-weight: 700; font-size: 16px; }
|
|
62
|
+
.name { color: var(--muted); font-size: 13px; }
|
|
63
|
+
.price { font-variant-numeric: tabular-nums; font-weight: 600; }
|
|
64
|
+
.up { color: var(--up); }
|
|
65
|
+
.down { color: var(--down); }
|
|
66
|
+
.meta {
|
|
67
|
+
display: flex;
|
|
68
|
+
gap: 12px;
|
|
69
|
+
color: var(--muted);
|
|
70
|
+
font-size: 12px;
|
|
71
|
+
margin-top: 8px;
|
|
72
|
+
flex-wrap: wrap;
|
|
73
|
+
}
|
|
74
|
+
svg { display: block; width: 100%; height: auto; }
|
|
75
|
+
`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {Object} HtmlPageOptions
|
|
80
|
+
* @property {string} [title] - Page title
|
|
81
|
+
* @property {'light'|'dark'} [theme] - Page + chart theme
|
|
82
|
+
* @property {'line'|'area'|'bar'|'candlestick'} [chartType] - Chart style
|
|
83
|
+
* @property {import('./svg.js').ChartOptions} [chartOptions] - Forwarded to renderer
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render a self-contained HTML page for a single stock or a market.
|
|
88
|
+
* @param {import('./generator.js').Stock | import('./generator.js').Stock[]} marketOrStock
|
|
89
|
+
* @param {HtmlPageOptions} [options]
|
|
90
|
+
* @returns {string} HTML document
|
|
91
|
+
*/
|
|
92
|
+
function renderHtmlPage(marketOrStock, options = {}) {
|
|
93
|
+
const stocks = Array.isArray(marketOrStock) ? marketOrStock : [marketOrStock];
|
|
94
|
+
const theme = options.theme === 'dark' ? 'dark' : 'light';
|
|
95
|
+
const chartType = options.chartType || 'line';
|
|
96
|
+
const chartOptions = {
|
|
97
|
+
width: 520,
|
|
98
|
+
height: 260,
|
|
99
|
+
theme,
|
|
100
|
+
...(options.chartOptions || {})
|
|
101
|
+
};
|
|
102
|
+
const title = options.title || 'Fake Stock Market';
|
|
103
|
+
|
|
104
|
+
const cards = stocks
|
|
105
|
+
.map((stock) => {
|
|
106
|
+
const last = stock.bars[stock.bars.length - 1].close;
|
|
107
|
+
const pct = changePct(stock);
|
|
108
|
+
const cls = pct >= 0 ? 'up' : 'down';
|
|
109
|
+
const sign = pct >= 0 ? '+' : '';
|
|
110
|
+
const chart = renderChart(stock, chartType, {
|
|
111
|
+
...chartOptions,
|
|
112
|
+
title: '' // header in card already shows the symbol
|
|
113
|
+
});
|
|
114
|
+
return `
|
|
115
|
+
<div class="card">
|
|
116
|
+
<div class="header">
|
|
117
|
+
<div>
|
|
118
|
+
<div class="symbol">${escapeHtml(stock.symbol)}</div>
|
|
119
|
+
${stock.name ? `<div class="name">${escapeHtml(stock.name)}</div>` : ''}
|
|
120
|
+
</div>
|
|
121
|
+
<div>
|
|
122
|
+
<span class="price">${last.toFixed(2)}</span>
|
|
123
|
+
<span class="${cls}">${sign}${pct.toFixed(2)}%</span>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
${chart}
|
|
127
|
+
<div class="meta">
|
|
128
|
+
${stock.sector ? `<span>${escapeHtml(stock.sector)}</span>` : ''}
|
|
129
|
+
<span>${stock.bars.length} bars</span>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
`;
|
|
133
|
+
})
|
|
134
|
+
.join('\n');
|
|
135
|
+
|
|
136
|
+
return `<!DOCTYPE html>
|
|
137
|
+
<html lang="en">
|
|
138
|
+
<head>
|
|
139
|
+
<meta charset="utf-8">
|
|
140
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
141
|
+
<title>${escapeHtml(title)}</title>
|
|
142
|
+
<style>${buildStyles(theme)}</style>
|
|
143
|
+
</head>
|
|
144
|
+
<body>
|
|
145
|
+
<h1>${escapeHtml(title)}</h1>
|
|
146
|
+
<p class="lead">Generated with stock-market-gen — fake data, no real market info.</p>
|
|
147
|
+
<div class="grid">
|
|
148
|
+
${cards}
|
|
149
|
+
</div>
|
|
150
|
+
</body>
|
|
151
|
+
</html>`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { renderHtmlPage };
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Public API.
|
|
2
|
+
|
|
3
|
+
const { generateStock, generateMarket } = require('./generator.cjs');
|
|
4
|
+
const { renderChart, renderLineChart, renderAreaChart, renderBarChart, renderCandlestickChart } = require('./svg.cjs');
|
|
5
|
+
const { renderHtmlPage } = require('./html.cjs');
|
|
6
|
+
const { createRng } = require('./prng.cjs');
|
|
7
|
+
const { parseInterval } = require('./interval.cjs');
|
|
8
|
+
|
|
9
|
+
module.exports = { generateStock, generateMarket, renderChart, renderLineChart, renderAreaChart, renderBarChart, renderCandlestickChart, renderHtmlPage, createRng, parseInterval };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Interval parsing. Accepts a number of milliseconds or a shorthand string
|
|
2
|
+
// like "1m", "5m", "1h", "1d", "1w", "1y".
|
|
3
|
+
|
|
4
|
+
const UNIT_MS = {
|
|
5
|
+
m: 60 * 1000,
|
|
6
|
+
h: 60 * 60 * 1000,
|
|
7
|
+
d: 24 * 60 * 60 * 1000,
|
|
8
|
+
w: 7 * 24 * 60 * 60 * 1000,
|
|
9
|
+
mo: 30 * 24 * 60 * 60 * 1000,
|
|
10
|
+
y: 365 * 24 * 60 * 60 * 1000
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Convert an interval (number in ms, or shorthand string) into milliseconds.
|
|
15
|
+
* @param {number|string} interval
|
|
16
|
+
* @returns {number}
|
|
17
|
+
*/
|
|
18
|
+
function parseInterval(interval) {
|
|
19
|
+
if (typeof interval === 'number') {
|
|
20
|
+
if (!Number.isFinite(interval) || interval <= 0) {
|
|
21
|
+
throw new Error(`Invalid interval: ${interval}`);
|
|
22
|
+
}
|
|
23
|
+
return interval;
|
|
24
|
+
}
|
|
25
|
+
if (typeof interval !== 'string') {
|
|
26
|
+
throw new Error(`Invalid interval: ${interval}`);
|
|
27
|
+
}
|
|
28
|
+
// "mo" must be matched before "m" so "1mo" doesn't read as "1m" + "o".
|
|
29
|
+
const match = /^(\d+)\s*(mo|m|h|d|w|y)$/i.exec(interval.trim());
|
|
30
|
+
if (!match) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Invalid interval string: "${interval}". Use e.g. "1m", "1h", "1d", "1w", "1mo", "1y".`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const n = Number(match[1]);
|
|
36
|
+
const unit = match[2].toLowerCase();
|
|
37
|
+
return n * UNIT_MS[unit];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { parseInterval };
|
package/dist/prng.cjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Seeded pseudo-random number generator.
|
|
2
|
+
// Mulberry32 is small, fast and good enough for fake data.
|
|
3
|
+
// Reference: https://stackoverflow.com/a/47593316
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hash a string seed into a 32-bit integer.
|
|
7
|
+
* @param {string} str
|
|
8
|
+
* @returns {number}
|
|
9
|
+
*/
|
|
10
|
+
function hashSeed(str) {
|
|
11
|
+
let h = 2166136261 >>> 0;
|
|
12
|
+
for (let i = 0; i < str.length; i++) {
|
|
13
|
+
h = Math.imul(h ^ str.charCodeAt(i), 16777619);
|
|
14
|
+
}
|
|
15
|
+
return h >>> 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a seeded random generator.
|
|
20
|
+
* @param {number|string|undefined} seed
|
|
21
|
+
* @returns {{ next: () => number, int: (min: number, max: number) => number, gauss: () => number, pick: <T>(arr: T[]) => T }}
|
|
22
|
+
*/
|
|
23
|
+
function createRng(seed) {
|
|
24
|
+
let state;
|
|
25
|
+
if (seed === undefined || seed === null) {
|
|
26
|
+
state = (Math.random() * 2 ** 32) >>> 0;
|
|
27
|
+
} else if (typeof seed === 'number') {
|
|
28
|
+
state = seed >>> 0;
|
|
29
|
+
} else {
|
|
30
|
+
state = hashSeed(String(seed));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Mulberry32 — uniform [0, 1)
|
|
34
|
+
function next() {
|
|
35
|
+
state = (state + 0x6D2B79F5) >>> 0;
|
|
36
|
+
let t = state;
|
|
37
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
38
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
39
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Integer in [min, max] inclusive
|
|
43
|
+
function int(min, max) {
|
|
44
|
+
return Math.floor(next() * (max - min + 1)) + min;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Standard normal via Box-Muller. Cached pair to avoid wasting samples.
|
|
48
|
+
let cached = null;
|
|
49
|
+
function gauss() {
|
|
50
|
+
if (cached !== null) {
|
|
51
|
+
const v = cached;
|
|
52
|
+
cached = null;
|
|
53
|
+
return v;
|
|
54
|
+
}
|
|
55
|
+
let u1 = next();
|
|
56
|
+
let u2 = next();
|
|
57
|
+
// Avoid log(0)
|
|
58
|
+
if (u1 < 1e-12) u1 = 1e-12;
|
|
59
|
+
const mag = Math.sqrt(-2 * Math.log(u1));
|
|
60
|
+
const z0 = mag * Math.cos(2 * Math.PI * u2);
|
|
61
|
+
const z1 = mag * Math.sin(2 * Math.PI * u2);
|
|
62
|
+
cached = z1;
|
|
63
|
+
return z0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pick(arr) {
|
|
67
|
+
return arr[int(0, arr.length - 1)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { next, int, gauss, pick };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { createRng };
|