Skip to content

Commit 4f1eb3a

Browse files
wip
1 parent ef361d3 commit 4f1eb3a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+654
-111
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ console.log(series.maxDrawdown()); // Max drawdown
2727
- **Date utilities**: Business day calendars (via date-holidays), date offsets, period-end alignment
2828
- **Captor API**: Fetch timeseries from Captor Open API (`fetchCaptorSeries`, `fetchCaptorSeriesBatch`)
2929
- **Report**: `reportHtml(frame, options)` — programmatic HTML report; CLI via `npm run report`
30+
- **Plot**: `plotSeriesHtml()`, `plotSeries()` — full-page series line chart (like Python `plot_series`)
3031

3132
## API Overview
3233

@@ -60,6 +61,11 @@ console.log(series.maxDrawdown()); // Max drawdown
6061

6162
- `reportHtml(frame, options?)` — generate HTML report (cumulative performance, annual returns, stats) from an OpenFrame. Options: `title`, `logoUrl`, `addLogo`. Countries for business-day metrics come from `frame.countries`.
6263

64+
### Plot
65+
66+
- `plotSeriesHtml(seriesOrFrame, options?)` — generate full-page HTML with a line chart of cumulative returns (100 base). Works with `OpenTimeSeries` or `OpenFrame` (use `mergeSeries("inner")` first). Options: `title`, `logoUrl`, `addLogo`.
67+
- `plotSeries(seriesOrFrame, options?)` — async: writes HTML to file and optionally opens in browser. Options: `title`, `logoUrl`, `addLogo`, `filename` (default: `~/Documents/plot.html`), `autoOpen` (default: true).
68+
6369
## Documentation
6470

6571
API reference: https://captorab.github.io/openseries-ts/
@@ -93,6 +99,9 @@ npm run docs # Generate API docs to docs/
9399
npm run report # Captor API report (--ids id1 id2 ... [--title, --countries, --filename, --no-open, --no-logo])
94100
npm run report:iris # Iris Bond + Benchmark preset
95101
npm run report:captor # Same as report (default Captor IDs)
102+
npm run plot:iris # Full-page plot (Captor Iris Bond + Benchmark)
103+
npm run plot # Plot with default Captor IDs
104+
npm run plot:captor # Same as plot
96105
```
97106

98107
## License

dist/index.cjs

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ __export(index_exports, {
5454
mean: () => mean,
5555
offsetBusinessDays: () => offsetBusinessDays,
5656
pctChange: () => pctChange,
57+
plotSeries: () => plotSeries,
58+
plotSeriesHtml: () => plotSeriesHtml,
5759
prevBusinessDay: () => prevBusinessDay,
5860
quantile: () => quantile,
5961
randomGenerator: () => randomGenerator,
@@ -2026,6 +2028,227 @@ function generateHtml(seriesData, reportTitle, stats, logoUrl) {
20262028
</body>
20272029
</html>`;
20282030
}
2031+
2032+
// src/plot.ts
2033+
var import_node_fs = require("fs");
2034+
var import_node_os = require("os");
2035+
var import_node_path = require("path");
2036+
var import_open = __toESM(require("open"), 1);
2037+
var DEFAULT_LOGO_URL2 = "https://sales.captor.se/captor_logo_sv_1600_icketransparent.png";
2038+
var DEFAULT_TITLE = "Series Plot";
2039+
function defaultOutputDir() {
2040+
const documents = (0, import_node_path.join)((0, import_node_os.homedir)(), "Documents");
2041+
return (0, import_node_fs.existsSync)(documents) ? documents : (0, import_node_os.homedir)();
2042+
}
2043+
function seriesToPlotData(series) {
2044+
if (series instanceof OpenTimeSeries) {
2045+
return [
2046+
{
2047+
name: series.label,
2048+
dates: series.dates,
2049+
values: series.values
2050+
}
2051+
];
2052+
}
2053+
const frame = series;
2054+
const { dates, columns } = frame.tsdf;
2055+
const colsFfilled = columns.map((col) => ffill(col));
2056+
return frame.columnLabels.map((name, i) => ({
2057+
name,
2058+
dates,
2059+
values: colsFfilled[i] ?? []
2060+
}));
2061+
}
2062+
function toCumulativeReturns(data) {
2063+
return data.map((s) => {
2064+
const vals = ffill(s.values);
2065+
const rets = pctChange(vals);
2066+
rets[0] = 0;
2067+
const cum = [100];
2068+
for (let i = 1; i < rets.length; i++) {
2069+
cum.push((cum[i - 1] ?? 0) * (1 + rets[i]));
2070+
}
2071+
return { name: s.name, dates: s.dates, values: cum };
2072+
});
2073+
}
2074+
var COLORWAY = [
2075+
"#66725B",
2076+
"#D0C0B1",
2077+
"#253551",
2078+
"#8D929D",
2079+
"#611A51",
2080+
"#402D16",
2081+
"#5D6C85",
2082+
"#404752"
2083+
];
2084+
function plotSeriesHtml(seriesOrFrame, options = {}) {
2085+
const title = options.title ?? DEFAULT_TITLE;
2086+
const logoUrl = options.addLogo !== false ? options.logoUrl ?? DEFAULT_LOGO_URL2 : "";
2087+
const rawData = seriesToPlotData(seriesOrFrame);
2088+
const cumData = toCumulativeReturns(rawData);
2089+
const chartColors = cumData.map((_, i) => COLORWAY[i % COLORWAY.length]);
2090+
const logoEl = logoUrl ? `<div class="plot-header-logo"><img src="${logoUrl}" alt="Logo" /></div>` : "<div></div>";
2091+
const titleEl = title !== "" ? `<h1 class="plot-title">${title}</h1>` : "<div></div>";
2092+
return `<!DOCTYPE html>
2093+
<html lang="en">
2094+
<head>
2095+
<meta charset="UTF-8">
2096+
<meta name="viewport" content="width=device-width, initial-scale=1">
2097+
<title>${title}</title>
2098+
<link rel="preconnect" href="https://fonts.googleapis.com">
2099+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
2100+
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
2101+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
2102+
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
2103+
<style>
2104+
* { box-sizing: border-box; margin: 0; padding: 0; }
2105+
html, body { height: 100%; overflow: hidden; }
2106+
body {
2107+
font-family: 'Poppins', -apple-system, BlinkMacSystemFont, sans-serif;
2108+
background: #fff;
2109+
color: #253551;
2110+
display: flex;
2111+
flex-direction: column;
2112+
min-height: 0;
2113+
}
2114+
.plot-header {
2115+
flex-shrink: 0;
2116+
display: grid;
2117+
grid-template-columns: 1fr auto 1fr;
2118+
align-items: center;
2119+
gap: 16px;
2120+
padding: 16px 24px;
2121+
min-height: 80px;
2122+
}
2123+
.plot-header-logo { display: flex; align-items: center; }
2124+
.plot-header-logo img { height: 60px; width: auto; max-width: 270px; object-fit: contain; }
2125+
.plot-title { grid-column: 2; text-align: center; font-size: 1.5rem; font-weight: 600; margin: 0; line-height: 1.2; }
2126+
@media (max-width: 768px) {
2127+
.plot-header { grid-template-columns: 1fr; grid-template-rows: auto auto; gap: 12px; text-align: center; }
2128+
.plot-header-logo { justify-content: center; grid-column: 1; }
2129+
.plot-header-logo img { height: 54px; max-width: 210px; }
2130+
.plot-title { grid-column: 1; grid-row: 2; font-size: 1.25rem; }
2131+
}
2132+
.plot-main {
2133+
flex: 1;
2134+
min-height: 0;
2135+
display: flex;
2136+
flex-direction: column;
2137+
padding: 24px;
2138+
}
2139+
.plot-wrapper {
2140+
flex: 1;
2141+
min-height: 200px;
2142+
position: relative;
2143+
}
2144+
.plot-wrapper canvas { max-width: 100%; }
2145+
.plot-legend {
2146+
flex-shrink: 0;
2147+
display: flex;
2148+
gap: 24px;
2149+
flex-wrap: wrap;
2150+
justify-content: center;
2151+
padding-top: 16px;
2152+
font-size: 0.8125rem;
2153+
color: #253551;
2154+
}
2155+
.plot-legend-item {
2156+
display: flex;
2157+
align-items: center;
2158+
gap: 8px;
2159+
}
2160+
.plot-legend-color {
2161+
width: 20px;
2162+
height: 3px;
2163+
border-radius: 2px;
2164+
flex-shrink: 0;
2165+
}
2166+
</style>
2167+
</head>
2168+
<body>
2169+
<header class="plot-header">
2170+
${logoEl}
2171+
${titleEl}
2172+
</header>
2173+
<div class="plot-main">
2174+
<div class="plot-wrapper"><canvas id="plotChart"></canvas></div>
2175+
<div class="plot-legend" id="legend"></div>
2176+
</div>
2177+
2178+
<script>
2179+
const cumData = ${JSON.stringify(cumData)};
2180+
const chartColors = ${JSON.stringify(chartColors)};
2181+
2182+
Chart.defaults.font.family = "'Poppins', sans-serif";
2183+
const ctx = document.getElementById('plotChart').getContext('2d');
2184+
new Chart(ctx, {
2185+
type: 'line',
2186+
data: {
2187+
datasets: cumData.map((s, i) => ({
2188+
label: s.name,
2189+
data: s.dates.map((d, j) => ({ x: d, y: s.values[j] })),
2190+
borderColor: chartColors[i],
2191+
backgroundColor: 'transparent',
2192+
borderWidth: 2,
2193+
tension: 0.1,
2194+
pointRadius: 0,
2195+
})),
2196+
},
2197+
options: {
2198+
responsive: true,
2199+
maintainAspectRatio: false,
2200+
scales: {
2201+
y: {
2202+
beginAtZero: false,
2203+
grid: { color: '#EEEEEE' },
2204+
ticks: { callback: v => v + '%' },
2205+
},
2206+
x: {
2207+
type: 'time',
2208+
grid: { color: '#EEEEEE' },
2209+
ticks: { maxRotation: 45 },
2210+
time: {
2211+
displayFormats: {
2212+
millisecond: 'HH:mm:ss',
2213+
second: 'HH:mm:ss',
2214+
minute: 'HH:mm',
2215+
hour: 'HH:mm',
2216+
day: 'MMM d',
2217+
week: 'MMM d',
2218+
month: 'MMM yyyy',
2219+
quarter: 'qqq yyyy',
2220+
year: 'yyyy',
2221+
},
2222+
tooltipFormat: 'yyyy-MM-dd',
2223+
},
2224+
},
2225+
},
2226+
plugins: { legend: { display: false } },
2227+
},
2228+
});
2229+
2230+
const legendEl = document.getElementById('legend');
2231+
chartColors.forEach((c, i) => {
2232+
const d = document.createElement('div');
2233+
d.className = 'plot-legend-item';
2234+
d.innerHTML = '<span class="plot-legend-color" style="background:' + c + '"></span><span>' + cumData[i].name + '</span>';
2235+
legendEl.appendChild(d);
2236+
});
2237+
</script>
2238+
</body>
2239+
</html>`;
2240+
}
2241+
async function plotSeries(seriesOrFrame, options = {}) {
2242+
const { filename, autoOpen = true } = options;
2243+
const html = plotSeriesHtml(seriesOrFrame, options);
2244+
const defaultDir = defaultOutputDir();
2245+
const plotPath = filename !== void 0 ? filename.includes("/") || filename.includes("\\") ? filename : (0, import_node_path.join)(defaultDir, filename) : (0, import_node_path.join)(defaultDir, "plot.html");
2246+
(0, import_node_fs.writeFileSync)(plotPath, html, "utf-8");
2247+
if (autoOpen) {
2248+
await (0, import_open.default)(plotPath, { wait: false });
2249+
}
2250+
return plotPath;
2251+
}
20292252
// Annotate the CommonJS export names for ESM import in node:
20302253
0 && (module.exports = {
20312254
DateAlignmentError,
@@ -2052,6 +2275,8 @@ function generateHtml(seriesData, reportTitle, stats, logoUrl) {
20522275
mean,
20532276
offsetBusinessDays,
20542277
pctChange,
2278+
plotSeries,
2279+
plotSeriesHtml,
20552280
prevBusinessDay,
20562281
quantile,
20572282
randomGenerator,

dist/index.d.cts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,4 +410,41 @@ interface ReportOptions {
410410
*/
411411
declare function reportHtml(frame: OpenFrame, options?: ReportOptions): string;
412412

413-
export { type CaptorSeriesResponse, type CountryCode, DateAlignmentError, type DateRangeOptions, type EfficientFrontierPoint, IncorrectArgumentComboError, InitialValueZeroError, type LiteralBizDayFreq, type LiteralPortfolioWeightings, MixedValuetypesError, NoWeightsError, OpenFrame, OpenTimeSeries, type RandomGenerator, type ReportOptions, ResampleDataLossError, type ResampleFreq, ReturnSimulation, type SimulatedPortfolio, ValueType, dateFix, dateToStr, efficientFrontier, fetchCaptorSeries, fetchCaptorSeriesBatch, filterBusinessDays, filterToBusinessDays, generateCalendarDateRange, isBusinessDay, lastBusinessDayOfMonth, lastBusinessDayOfYear, mean, offsetBusinessDays, pctChange, prevBusinessDay, quantile, randomGenerator, reportHtml, resampleToPeriodEnd, simulatePortfolios, std, timeseriesChain };
413+
/**
414+
* Full-page series plot (line chart) output.
415+
* Analogous to Python openseries plot_series: single full-page HTML with optional title.
416+
*/
417+
418+
interface PlotSeriesOptions {
419+
/** Optional title above the chart. */
420+
title?: string;
421+
/** Logo URL (e.g. company logo). Shown in upper left when addLogo is true. */
422+
logoUrl?: string;
423+
/** If true, show logo in upper left. Default: true. */
424+
addLogo?: boolean;
425+
/** Output file path. Default: ~/Documents/plot.html (or ~/ if Documents missing). */
426+
filename?: string;
427+
/** If true, open the HTML file in the default browser. Default: true. */
428+
autoOpen?: boolean;
429+
}
430+
/**
431+
* Generate full-page HTML with a line chart of the series (or multiple series).
432+
* Plots cumulative returns (100 base) like Python plot_series.
433+
* Works with OpenTimeSeries or OpenFrame. For OpenFrame, use mergeSeries("inner") first.
434+
*
435+
* @param seriesOrFrame - OpenTimeSeries or OpenFrame
436+
* @param options - Optional title
437+
* @returns HTML string
438+
*/
439+
declare function plotSeriesHtml(seriesOrFrame: OpenTimeSeries | OpenFrame, options?: PlotSeriesOptions): string;
440+
/**
441+
* Generate full-page series plot HTML, write to file, and optionally open in browser.
442+
* Analogous to Python plot_series with auto_open.
443+
*
444+
* @param seriesOrFrame - OpenTimeSeries or OpenFrame (use mergeSeries("inner") for frame)
445+
* @param options - Title, filename, autoOpen
446+
* @returns Path to the written HTML file
447+
*/
448+
declare function plotSeries(seriesOrFrame: OpenTimeSeries | OpenFrame, options?: PlotSeriesOptions): Promise<string>;
449+
450+
export { type CaptorSeriesResponse, type CountryCode, DateAlignmentError, type DateRangeOptions, type EfficientFrontierPoint, IncorrectArgumentComboError, InitialValueZeroError, type LiteralBizDayFreq, type LiteralPortfolioWeightings, MixedValuetypesError, NoWeightsError, OpenFrame, OpenTimeSeries, type PlotSeriesOptions, type RandomGenerator, type ReportOptions, ResampleDataLossError, type ResampleFreq, ReturnSimulation, type SimulatedPortfolio, ValueType, dateFix, dateToStr, efficientFrontier, fetchCaptorSeries, fetchCaptorSeriesBatch, filterBusinessDays, filterToBusinessDays, generateCalendarDateRange, isBusinessDay, lastBusinessDayOfMonth, lastBusinessDayOfYear, mean, offsetBusinessDays, pctChange, plotSeries, plotSeriesHtml, prevBusinessDay, quantile, randomGenerator, reportHtml, resampleToPeriodEnd, simulatePortfolios, std, timeseriesChain };

dist/index.d.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,4 +410,41 @@ interface ReportOptions {
410410
*/
411411
declare function reportHtml(frame: OpenFrame, options?: ReportOptions): string;
412412

413-
export { type CaptorSeriesResponse, type CountryCode, DateAlignmentError, type DateRangeOptions, type EfficientFrontierPoint, IncorrectArgumentComboError, InitialValueZeroError, type LiteralBizDayFreq, type LiteralPortfolioWeightings, MixedValuetypesError, NoWeightsError, OpenFrame, OpenTimeSeries, type RandomGenerator, type ReportOptions, ResampleDataLossError, type ResampleFreq, ReturnSimulation, type SimulatedPortfolio, ValueType, dateFix, dateToStr, efficientFrontier, fetchCaptorSeries, fetchCaptorSeriesBatch, filterBusinessDays, filterToBusinessDays, generateCalendarDateRange, isBusinessDay, lastBusinessDayOfMonth, lastBusinessDayOfYear, mean, offsetBusinessDays, pctChange, prevBusinessDay, quantile, randomGenerator, reportHtml, resampleToPeriodEnd, simulatePortfolios, std, timeseriesChain };
413+
/**
414+
* Full-page series plot (line chart) output.
415+
* Analogous to Python openseries plot_series: single full-page HTML with optional title.
416+
*/
417+
418+
interface PlotSeriesOptions {
419+
/** Optional title above the chart. */
420+
title?: string;
421+
/** Logo URL (e.g. company logo). Shown in upper left when addLogo is true. */
422+
logoUrl?: string;
423+
/** If true, show logo in upper left. Default: true. */
424+
addLogo?: boolean;
425+
/** Output file path. Default: ~/Documents/plot.html (or ~/ if Documents missing). */
426+
filename?: string;
427+
/** If true, open the HTML file in the default browser. Default: true. */
428+
autoOpen?: boolean;
429+
}
430+
/**
431+
* Generate full-page HTML with a line chart of the series (or multiple series).
432+
* Plots cumulative returns (100 base) like Python plot_series.
433+
* Works with OpenTimeSeries or OpenFrame. For OpenFrame, use mergeSeries("inner") first.
434+
*
435+
* @param seriesOrFrame - OpenTimeSeries or OpenFrame
436+
* @param options - Optional title
437+
* @returns HTML string
438+
*/
439+
declare function plotSeriesHtml(seriesOrFrame: OpenTimeSeries | OpenFrame, options?: PlotSeriesOptions): string;
440+
/**
441+
* Generate full-page series plot HTML, write to file, and optionally open in browser.
442+
* Analogous to Python plot_series with auto_open.
443+
*
444+
* @param seriesOrFrame - OpenTimeSeries or OpenFrame (use mergeSeries("inner") for frame)
445+
* @param options - Title, filename, autoOpen
446+
* @returns Path to the written HTML file
447+
*/
448+
declare function plotSeries(seriesOrFrame: OpenTimeSeries | OpenFrame, options?: PlotSeriesOptions): Promise<string>;
449+
450+
export { type CaptorSeriesResponse, type CountryCode, DateAlignmentError, type DateRangeOptions, type EfficientFrontierPoint, IncorrectArgumentComboError, InitialValueZeroError, type LiteralBizDayFreq, type LiteralPortfolioWeightings, MixedValuetypesError, NoWeightsError, OpenFrame, OpenTimeSeries, type PlotSeriesOptions, type RandomGenerator, type ReportOptions, ResampleDataLossError, type ResampleFreq, ReturnSimulation, type SimulatedPortfolio, ValueType, dateFix, dateToStr, efficientFrontier, fetchCaptorSeries, fetchCaptorSeriesBatch, filterBusinessDays, filterToBusinessDays, generateCalendarDateRange, isBusinessDay, lastBusinessDayOfMonth, lastBusinessDayOfYear, mean, offsetBusinessDays, pctChange, plotSeries, plotSeriesHtml, prevBusinessDay, quantile, randomGenerator, reportHtml, resampleToPeriodEnd, simulatePortfolios, std, timeseriesChain };

0 commit comments

Comments
 (0)