Remove unused files and dependencies

This commit is contained in:
Elias 2025-03-25 19:15:14 +00:00
parent deab1a970c
commit 5e3a5b4914
37 changed files with 0 additions and 3227 deletions

View file

@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
typings
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -1,38 +0,0 @@
# Custom Primitives - Lightweight Charts™ Plugin
Description of the Plugin.
- Developed for Lightweight Charts version: `v4.1.0`
## Running Locally
```shell
npm install
npm run dev
```
Visit `localhost:5173` in the browser.
## Compiling
```shell
npm run compile
```
Check the output in the `dist` folder.
## Publishing To NPM
You can configure the contents of the package's `package.json` within the
`compile.mjs` script.
Once you have compiled the plugin (see above section) then you can publish the
package to NPM with these commands:
```shell
cd dist
npm publish
```
Hint: append `--dry-run` to the end of the publish command to see the results of
the publish command without actually uploading the package to NPM.

View file

@ -1,116 +0,0 @@
import { dirname, resolve } from 'node:path';
import { copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { build, defineConfig } from 'vite';
import { fileURLToPath } from 'url';
import { generateDtsBundle } from 'dts-bundle-generator';
function buildPackageJson(packageName) {
/*
Define the contents of the package's package.json here.
*/
return {
name: packageName,
version: '1.0.0',
keywords: ['lwc-plugin', 'lightweight-charts'],
type: 'module',
main: `./${packageName}.umd.cjs`,
module: `./${packageName}.js`,
types: `./${packageName}.d.ts`,
exports: {
import: {
types: `./${packageName}.d.ts`,
default: `./${packageName}.js`,
},
require: {
types: `./${packageName}.d.cts`,
default: `./${packageName}.umd.cjs`,
},
},
};
}
const __filename = fileURLToPath(import.meta.url);
const currentDir = dirname(__filename);
const pluginFileName = 'custom-primitives';
const pluginFile = resolve(currentDir, 'src', `${pluginFileName}.ts`);
const pluginsToBuild = [
{
filepath: pluginFile,
exportName: 'lwc-plugin-custom-primitives',
name: 'CustomPrimitives',
},
];
const compiledFolder = resolve(currentDir, 'dist');
if (!existsSync(compiledFolder)) {
mkdirSync(compiledFolder);
}
const buildConfig = ({
filepath,
name,
exportName,
formats = ['es', 'umd'],
}) => {
return defineConfig({
publicDir: false,
build: {
outDir: `dist`,
emptyOutDir: true,
copyPublicDir: false,
lib: {
entry: filepath,
name,
formats,
fileName: exportName,
},
rollupOptions: {
external: ['lightweight-charts', 'fancy-canvas'],
output: {
globals: {
'lightweight-charts': 'LightweightCharts',
},
},
},
},
});
};
const startTime = Date.now().valueOf();
console.log('⚡️ Starting');
console.log('Bundling the plugin...');
const promises = pluginsToBuild.map(file => {
return build(buildConfig(file));
});
await Promise.all(promises);
console.log('Generating the package.json file...');
pluginsToBuild.forEach(file => {
const packagePath = resolve(compiledFolder, 'package.json');
const content = JSON.stringify(
buildPackageJson(file.exportName),
undefined,
4
);
writeFileSync(packagePath, content, { encoding: 'utf-8' });
});
console.log('Generating the typings files...');
pluginsToBuild.forEach(file => {
try {
const esModuleTyping = generateDtsBundle([
{
filePath: `./typings/${pluginFileName}.d.ts`,
},
]);
const typingFilePath = resolve(compiledFolder, `${file.exportName}.d.ts`);
writeFileSync(typingFilePath, esModuleTyping.join('\n'), {
encoding: 'utf-8',
});
copyFileSync(typingFilePath, resolve(compiledFolder, `${file.exportName}.d.cts`));
} catch (e) {
console.error('Error generating typings for: ', file.exportName);
}
});
const endTime = Date.now().valueOf();
console.log(`🎉 Done (${endTime - startTime}ms)`);

View file

@ -1,7 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<!-- redirect to example page -->
<meta http-equiv="refresh" content="0; URL=src/example/" />
</head>
</html>

View file

@ -1,17 +0,0 @@
{
"name": "lwc-plugin-custom-primitives",
"type": "module",
"scripts": {
"dev": "vite --config src/vite.config.js",
"compile": "tsc && node compile.mjs"
},
"devDependencies": {
"typescript": "^5.0.4",
"vite": "^4.3.1"
},
"dependencies": {
"dts-bundle-generator": "^8.0.1",
"fancy-canvas": "^2.1.0",
"lightweight-charts": "^4.1.0-rc2"
}
}

View file

@ -1,41 +0,0 @@
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { ISeriesPrimitivePaneRenderer } from 'lightweight-charts';
import { positionsBox } from './helpers/dimensions/positions';
export class CustomPrimitivesAxisPaneRenderer implements ISeriesPrimitivePaneRenderer {
_p1: number | null;
_p2: number | null;
_fillColor: string;
_vertical: boolean = false;
constructor(
p1: number | null,
p2: number | null,
fillColor: string,
vertical: boolean
) {
this._p1 = p1;
this._p2 = p2;
this._fillColor = fillColor;
this._vertical = vertical;
}
draw(target: CanvasRenderingTarget2D) {
target.useBitmapCoordinateSpace(scope => {
if (this._p1 === null || this._p2 === null) return;
const ctx = scope.context;
ctx.globalAlpha = 0.5;
const positions = positionsBox(
this._p1,
this._p2,
this._vertical ? scope.verticalPixelRatio : scope.horizontalPixelRatio
);
ctx.fillStyle = this._fillColor;
if (this._vertical) {
ctx.fillRect(0, positions.position, 15, positions.length);
} else {
ctx.fillRect(positions.position, 0, positions.length, 15);
}
});
}
}

View file

@ -1,55 +0,0 @@
import {
Coordinate,
ISeriesPrimitivePaneView,
SeriesPrimitivePaneViewZOrder,
} from 'lightweight-charts';
import { CustomPrimitivesAxisPaneRenderer } from './axis-pane-renderer';
import { CustomPrimitivesDataSource } from './data-source';
abstract class CustomPrimitivesAxisPaneView implements ISeriesPrimitivePaneView {
_source: CustomPrimitivesDataSource;
_p1: number | null = null;
_p2: number | null = null;
_vertical: boolean = false;
constructor(source: CustomPrimitivesDataSource, vertical: boolean) {
this._source = source;
this._vertical = vertical;
}
abstract getPoints(): [Coordinate | null, Coordinate | null];
update() {
[this._p1, this._p2] = this.getPoints();
}
renderer() {
return new CustomPrimitivesAxisPaneRenderer(
this._p1,
this._p2,
this._source.options.fillColor,
this._vertical
);
}
zOrder(): SeriesPrimitivePaneViewZOrder {
return 'bottom';
}
}
export class CustomPrimitivesPriceAxisPaneView extends CustomPrimitivesAxisPaneView {
getPoints(): [Coordinate | null, Coordinate | null] {
const series = this._source.series;
const y1 = series.priceToCoordinate(this._source.p1.price);
const y2 = series.priceToCoordinate(this._source.p2.price);
return [y1, y2];
}
}
export class CustomPrimitivesTimeAxisPaneView extends CustomPrimitivesAxisPaneView {
getPoints(): [Coordinate | null, Coordinate | null] {
const timeScale = this._source.chart.timeScale();
const x1 = timeScale.timeToCoordinate(this._source.p1.time);
const x2 = timeScale.timeToCoordinate(this._source.p2.time);
return [x1, x2];
}
}

View file

@ -1,57 +0,0 @@
import { Coordinate, ISeriesPrimitiveAxisView } from 'lightweight-charts';
import { Point, CustomPrimitivesDataSource } from './data-source';
abstract class CustomPrimitivesAxisView implements ISeriesPrimitiveAxisView {
_source: CustomPrimitivesDataSource;
_p: Point;
_pos: Coordinate | null = null;
constructor(source: CustomPrimitivesDataSource, p: Point) {
this._source = source;
this._p = p;
}
abstract update(): void;
abstract text(): string;
coordinate() {
return this._pos ?? -1;
}
visible(): boolean {
return this._source.options.showLabels;
}
tickVisible(): boolean {
return this._source.options.showLabels;
}
textColor() {
return this._source.options.labelTextColor;
}
backColor() {
return this._source.options.labelColor;
}
movePoint(p: Point) {
this._p = p;
this.update();
}
}
export class CustomPrimitivesTimeAxisView extends CustomPrimitivesAxisView {
update() {
const timeScale = this._source.chart.timeScale();
this._pos = timeScale.timeToCoordinate(this._p.time);
}
text() {
return this._source.options.timeLabelFormatter(this._p.time);
}
}
export class CustomPrimitivesPriceAxisView extends CustomPrimitivesAxisView {
update() {
const series = this._source.series;
this._pos = series.priceToCoordinate(this._p.price);
}
text() {
return this._source.options.priceLabelFormatter(this._p.price);
}
}

View file

@ -1,139 +0,0 @@
import { AutoscaleInfo, Logical, Time, DataChangedScope } from 'lightweight-charts';
import {
CustomPrimitivesPriceAxisPaneView,
CustomPrimitivesTimeAxisPaneView,
} from './axis-pane-view';
import { CustomPrimitivesPriceAxisView, CustomPrimitivesTimeAxisView } from './axis-view';
import { Point, CustomPrimitivesDataSource } from './data-source';
import { CustomPrimitivesOptions, defaultOptions } from './options';
import { CustomPrimitivesPaneView } from './pane-view';
import { PluginBase } from './plugin-base';
export class CustomPrimitives
extends PluginBase
implements CustomPrimitivesDataSource
{
_options: CustomPrimitivesOptions;
_p1: Point;
_p2: Point;
_paneViews: CustomPrimitivesPaneView[];
_timeAxisViews: CustomPrimitivesTimeAxisView[];
_priceAxisViews: CustomPrimitivesPriceAxisView[];
_priceAxisPaneViews: CustomPrimitivesPriceAxisPaneView[];
_timeAxisPaneViews: CustomPrimitivesTimeAxisPaneView[];
constructor(
p1: Point,
p2: Point,
options: Partial<CustomPrimitivesOptions> = {}
) {
super();
this._p1 = p1;
this._p2 = p2;
this._options = {
...defaultOptions,
...options,
};
this._paneViews = [new CustomPrimitivesPaneView(this)];
this._timeAxisViews = [
new CustomPrimitivesTimeAxisView(this, p1),
new CustomPrimitivesTimeAxisView(this, p2),
];
this._priceAxisViews = [
new CustomPrimitivesPriceAxisView(this, p1),
new CustomPrimitivesPriceAxisView(this, p2),
];
this._priceAxisPaneViews = [new CustomPrimitivesPriceAxisPaneView(this, true)];
this._timeAxisPaneViews = [new CustomPrimitivesTimeAxisPaneView(this, false)];
}
updateAllViews() {
//* Use this method to update any data required by the
//* views to draw.
this._paneViews.forEach(pw => pw.update());
this._timeAxisViews.forEach(pw => pw.update());
this._priceAxisViews.forEach(pw => pw.update());
this._priceAxisPaneViews.forEach(pw => pw.update());
this._timeAxisPaneViews.forEach(pw => pw.update());
}
priceAxisViews() {
//* Labels rendered on the price scale
return this._priceAxisViews;
}
timeAxisViews() {
//* labels rendered on the time scale
return this._timeAxisViews;
}
paneViews() {
//* rendering on the main chart pane
return this._paneViews;
}
priceAxisPaneViews() {
//* rendering on the price scale
return this._priceAxisPaneViews;
}
timeAxisPaneViews() {
//* rendering on the time scale
return this._timeAxisPaneViews;
}
autoscaleInfo(
startTimePoint: Logical,
endTimePoint: Logical
): AutoscaleInfo | null {
//* Use this method to provide autoscale information if your primitive
//* should have the ability to remain in view automatically.
if (
this._timeCurrentlyVisible(this.p1.time, startTimePoint, endTimePoint) ||
this._timeCurrentlyVisible(this.p2.time, startTimePoint, endTimePoint)
) {
return {
priceRange: {
minValue: Math.min(this.p1.price, this.p2.price),
maxValue: Math.max(this.p1.price, this.p2.price),
},
};
}
return null;
}
dataUpdated(scope: DataChangedScope): void {
//* This method will be called by PluginBase when the data on the
//* series has changed.
}
_timeCurrentlyVisible(
time: Time,
startTimePoint: Logical,
endTimePoint: Logical
): boolean {
const ts = this.chart.timeScale();
const coordinate = ts.timeToCoordinate(time);
if (coordinate === null) return false;
const logical = ts.coordinateToLogical(coordinate);
if (logical === null) return false;
return logical <= endTimePoint && logical >= startTimePoint;
}
public get options(): CustomPrimitivesOptions {
return this._options;
}
applyOptions(options: Partial<CustomPrimitivesOptions>) {
this._options = { ...this._options, ...options };
this.requestUpdate();
}
public get p1(): Point {
return this._p1;
}
public get p2(): Point {
return this._p2;
}
}

View file

@ -1,20 +0,0 @@
import {
IChartApi,
ISeriesApi,
SeriesOptionsMap,
Time,
} from 'lightweight-charts';
import { CustomPrimitivesOptions } from './options';
export interface Point {
time: Time;
price: number;
}
export interface CustomPrimitivesDataSource {
chart: IChartApi;
series: ISeriesApi<keyof SeriesOptionsMap>;
options: CustomPrimitivesOptions;
p1: Point;
p2: Point;
}

View file

@ -1,23 +0,0 @@
import { createChart } from 'lightweight-charts';
import { generateLineData } from '../sample-data';
import { CustomPrimitives } from '../custom-primitives';
const chart = ((window as unknown as any).chart = createChart('chart', {
autoSize: true,
}));
const lineSeries = chart.addLineSeries({
color: '#000000',
});
const data = generateLineData();
lineSeries.setData(data);
const time1 = data[data.length - 50].time;
const time2 = data[data.length - 10].time;
const primitive = new CustomPrimitives(
{ price: 100, time: time1 },
{ price: 500, time: time2 }
);
lineSeries.attachPrimitive(primitive);

View file

@ -1,26 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Template Drawing Primitive Plugin Example</title>
<style>
body {
background-color: rgba(248, 249, 253, 1);
color: rgba(19, 23, 34, 1);
}
#chart {
margin-inline: auto;
max-width: 600px;
height: 300px;
background-color: rgba(240, 243, 250, 1);
border-radius: 5px;
overflow: hidden;
}
</style>
</head>
<body>
<div id="chart"></div>
<script type="module" src="./example.ts"></script>
</body>
</html>

View file

@ -1,33 +0,0 @@
/**
* Ensures that value is defined.
* Throws if the value is undefined, returns the original value otherwise.
*
* @param value - The value, or undefined.
* @returns The passed value, if it is not undefined
*/
export function ensureDefined(value: undefined): never;
export function ensureDefined<T>(value: T | undefined): T;
export function ensureDefined<T>(value: T | undefined): T {
if (value === undefined) {
throw new Error('Value is undefined');
}
return value;
}
/**
* Ensures that value is not null.
* Throws if the value is null, returns the original value otherwise.
*
* @param value - The value, or null.
* @returns The passed value, if it is not null
*/
export function ensureNotNull(value: null): never;
export function ensureNotNull<T>(value: T | null): T;
export function ensureNotNull<T>(value: T | null): T {
if (value === null) {
throw new Error('Value is null');
}
return value;
}

View file

@ -1,6 +0,0 @@
export interface BitmapPositionLength {
/** coordinate for use with a bitmap rendering scope */
position: number;
/** length for use with a bitmap rendering scope */
length: number;
}

View file

@ -1,23 +0,0 @@
/**
* Default grid / crosshair line width in Bitmap sizing
* @param horizontalPixelRatio - horizontal pixel ratio
* @returns default grid / crosshair line width in Bitmap sizing
*/
export function gridAndCrosshairBitmapWidth(
horizontalPixelRatio: number
): number {
return Math.max(1, Math.floor(horizontalPixelRatio));
}
/**
* Default grid / crosshair line width in Media sizing
* @param horizontalPixelRatio - horizontal pixel ratio
* @returns default grid / crosshair line width in Media sizing
*/
export function gridAndCrosshairMediaWidth(
horizontalPixelRatio: number
): number {
return (
gridAndCrosshairBitmapWidth(horizontalPixelRatio) / horizontalPixelRatio
);
}

View file

@ -1,29 +0,0 @@
import { BitmapPositionLength } from './common';
/**
* Calculates the position and width which will completely full the space for the bar.
* Useful if you want to draw something that will not have any gaps between surrounding bars.
* @param xMedia - x coordinate of the bar defined in media sizing
* @param halfBarSpacingMedia - half the width of the current barSpacing (un-rounded)
* @param horizontalPixelRatio - horizontal pixel ratio
* @returns position and width which will completely full the space for the bar
*/
export function fullBarWidth(
xMedia: number,
halfBarSpacingMedia: number,
horizontalPixelRatio: number
): BitmapPositionLength {
const fullWidthLeftMedia = xMedia - halfBarSpacingMedia;
const fullWidthRightMedia = xMedia + halfBarSpacingMedia;
const fullWidthLeftBitmap = Math.round(
fullWidthLeftMedia * horizontalPixelRatio
);
const fullWidthRightBitmap = Math.round(
fullWidthRightMedia * horizontalPixelRatio
);
const fullWidthBitmap = fullWidthRightBitmap - fullWidthLeftBitmap;
return {
position: fullWidthLeftBitmap,
length: fullWidthBitmap,
};
}

View file

@ -1,48 +0,0 @@
import { BitmapPositionLength } from './common';
function centreOffset(lineBitmapWidth: number): number {
return Math.floor(lineBitmapWidth * 0.5);
}
/**
* Calculates the bitmap position for an item with a desired length (height or width), and centred according to
* an position coordinate defined in media sizing.
* @param positionMedia - position coordinate for the bar (in media coordinates)
* @param pixelRatio - pixel ratio. Either horizontal for x positions, or vertical for y positions
* @param desiredWidthMedia - desired width (in media coordinates)
* @returns Position of of the start point and length dimension.
*/
export function positionsLine(
positionMedia: number,
pixelRatio: number,
desiredWidthMedia: number = 1,
widthIsBitmap?: boolean
): BitmapPositionLength {
const scaledPosition = Math.round(pixelRatio * positionMedia);
const lineBitmapWidth = widthIsBitmap
? desiredWidthMedia
: Math.round(desiredWidthMedia * pixelRatio);
const offset = centreOffset(lineBitmapWidth);
const position = scaledPosition - offset;
return { position, length: lineBitmapWidth };
}
/**
* Determines the bitmap position and length for a dimension of a shape to be drawn.
* @param position1Media - media coordinate for the first point
* @param position2Media - media coordinate for the second point
* @param pixelRatio - pixel ratio for the corresponding axis (vertical or horizontal)
* @returns Position of of the start point and length dimension.
*/
export function positionsBox(
position1Media: number,
position2Media: number,
pixelRatio: number
): BitmapPositionLength {
const scaledPosition1 = Math.round(pixelRatio * position1Media);
const scaledPosition2 = Math.round(pixelRatio * position2Media);
return {
position: Math.min(scaledPosition1, scaledPosition2),
length: Math.abs(scaledPosition2 - scaledPosition1) + 1,
};
}

View file

@ -1,34 +0,0 @@
import { Time, isUTCTimestamp, isBusinessDay } from 'lightweight-charts';
export function convertTime(t: Time): number {
if (isUTCTimestamp(t)) return t * 1000;
if (isBusinessDay(t)) return new Date(t.year, t.month, t.day).valueOf();
const [year, month, day] = t.split('-').map(parseInt);
return new Date(year, month, day).valueOf();
}
export function displayTime(time: Time): string {
if (typeof time == 'string') return time;
const date = isBusinessDay(time)
? new Date(time.year, time.month, time.day)
: new Date(time * 1000);
return date.toLocaleDateString();
}
export function formattedDateAndTime(timestamp: number | undefined): [string, string] {
if (!timestamp) return ['', ''];
const dateObj = new Date(timestamp);
// Format date string
const year = dateObj.getFullYear();
const month = dateObj.toLocaleString('default', { month: 'short' });
const date = dateObj.getDate().toString().padStart(2, '0');
const formattedDate = `${date} ${month} ${year}`;
// Format time string
const hours = dateObj.getHours().toString().padStart(2, '0');
const minutes = dateObj.getMinutes().toString().padStart(2, '0');
const formattedTime = `${hours}:${minutes}`;
return [formattedDate, formattedTime];
}

View file

@ -1,27 +0,0 @@
import { Time, isBusinessDay } from 'lightweight-charts';
export interface CustomPrimitivesOptions {
//* Define the options for the primitive.
fillColor: string;
labelColor: string;
labelTextColor: string;
showLabels: boolean;
priceLabelFormatter: (price: number) => string;
timeLabelFormatter: (time: Time) => string;
}
export const defaultOptions: CustomPrimitivesOptions = {
//* Define the default values for all the primitive options.
fillColor: 'rgba(200, 50, 100, 0.75)',
labelColor: 'rgba(200, 50, 100, 1)',
labelTextColor: 'white',
showLabels: true,
priceLabelFormatter: (price: number) => price.toFixed(2),
timeLabelFormatter: (time: Time) => {
if (typeof time == 'string') return time;
const date = isBusinessDay(time)
? new Date(time.year, time.month, time.day)
: new Date(time * 1000);
return date.toLocaleDateString();
},
} as const;

View file

@ -1,46 +0,0 @@
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { ISeriesPrimitivePaneRenderer } from 'lightweight-charts';
import { ViewPoint } from './pane-view';
import { positionsBox } from './helpers/dimensions/positions';
export class CustomPrimitivesPaneRenderer implements ISeriesPrimitivePaneRenderer {
_p1: ViewPoint;
_p2: ViewPoint;
_fillColor: string;
constructor(p1: ViewPoint, p2: ViewPoint, fillColor: string) {
this._p1 = p1;
this._p2 = p2;
this._fillColor = fillColor;
}
draw(target: CanvasRenderingTarget2D) {
target.useBitmapCoordinateSpace(scope => {
if (
this._p1.x === null ||
this._p1.y === null ||
this._p2.x === null ||
this._p2.y === null
)
return;
const ctx = scope.context;
const horizontalPositions = positionsBox(
this._p1.x,
this._p2.x,
scope.horizontalPixelRatio
);
const verticalPositions = positionsBox(
this._p1.y,
this._p2.y,
scope.verticalPixelRatio
);
ctx.fillStyle = this._fillColor;
ctx.fillRect(
horizontalPositions.position,
verticalPositions.position,
horizontalPositions.length,
verticalPositions.length
);
});
}
}

View file

@ -1,37 +0,0 @@
import { Coordinate, ISeriesPrimitivePaneView } from 'lightweight-charts';
import { CustomPrimitivesPaneRenderer } from './pane-renderer';
import { CustomPrimitivesDataSource } from './data-source';
export interface ViewPoint {
x: Coordinate | null;
y: Coordinate | null;
}
export class CustomPrimitivesPaneView implements ISeriesPrimitivePaneView {
_source: CustomPrimitivesDataSource;
_p1: ViewPoint = { x: null, y: null };
_p2: ViewPoint = { x: null, y: null };
constructor(source: CustomPrimitivesDataSource) {
this._source = source;
}
update() {
const series = this._source.series;
const y1 = series.priceToCoordinate(this._source.p1.price);
const y2 = series.priceToCoordinate(this._source.p2.price);
const timeScale = this._source.chart.timeScale();
const x1 = timeScale.timeToCoordinate(this._source.p1.time);
const x2 = timeScale.timeToCoordinate(this._source.p2.time);
this._p1 = { x: x1, y: y1 };
this._p2 = { x: x2, y: y2 };
}
renderer() {
return new CustomPrimitivesPaneRenderer(
this._p1,
this._p2,
this._source.options.fillColor
);
}
}

View file

@ -1,56 +0,0 @@
import {
DataChangedScope,
IChartApi,
ISeriesApi,
ISeriesPrimitive,
SeriesAttachedParameter,
SeriesOptionsMap,
Time,
} from 'lightweight-charts';
import { ensureDefined } from './helpers/assertions';
//* PluginBase is a useful base to build a plugin upon which
//* already handles creating getters for the chart and series,
//* and provides a requestUpdate method.
export abstract class PluginBase implements ISeriesPrimitive<Time> {
private _chart: IChartApi | undefined = undefined;
private _series: ISeriesApi<keyof SeriesOptionsMap> | undefined = undefined;
protected dataUpdated?(scope: DataChangedScope): void;
protected requestUpdate(): void {
if (this._requestUpdate) this._requestUpdate();
}
private _requestUpdate?: () => void;
public attached({
chart,
series,
requestUpdate,
}: SeriesAttachedParameter<Time>) {
this._chart = chart;
this._series = series;
this._series.subscribeDataChanged(this._fireDataUpdated);
this._requestUpdate = requestUpdate;
this.requestUpdate();
}
public detached() {
this._chart = undefined;
this._series = undefined;
this._requestUpdate = undefined;
}
public get chart(): IChartApi {
return ensureDefined(this._chart);
}
public get series(): ISeriesApi<keyof SeriesOptionsMap> {
return ensureDefined(this._series);
}
private _fireDataUpdated(scope: DataChangedScope) {
if (this.dataUpdated) {
this.dataUpdated(scope);
}
}
}

View file

@ -1,35 +0,0 @@
import type { Time } from 'lightweight-charts';
type LineData = {
time: Time;
value: number;
};
let randomFactor = 25 + Math.random() * 25;
const samplePoint = (i: number) =>
i *
(0.5 +
Math.sin(i / 10) * 0.2 +
Math.sin(i / 20) * 0.4 +
Math.sin(i / randomFactor) * 0.8 +
Math.sin(i / 500) * 0.5) +
200;
export function generateLineData(numberOfPoints: number = 500): LineData[] {
randomFactor = 25 + Math.random() * 25;
const res = [];
const date = new Date(Date.UTC(2023, 0, 1, 12, 0, 0, 0));
for (let i = 0; i < numberOfPoints; ++i) {
const time = (date.getTime() / 1000) as Time;
const value = samplePoint(i);
res.push({
time,
value,
});
date.setUTCDate(date.getUTCDate() + 1);
}
return res;
}

View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -1,13 +0,0 @@
import { defineConfig } from 'vite';
const input = {
main: './src/example/index.html',
};
export default defineConfig({
build: {
rollupOptions: {
input,
},
},
});

View file

@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"moduleResolution": "Node",
"strict": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true,
"declaration": true,
"declarationDir": "typings",
"emitDeclarationOnly": true
},
"include": ["src"]
}

View file

@ -1,133 +0,0 @@
// src/components/ComponentRenderer.tsx
import React, { useState, useEffect } from 'react';
import { transform } from '@babel/standalone';
import { ErrorBoundary } from './ErrorBoundary';
import { App } from 'obsidian';
import { useStorage } from '../hooks/useStorage';
import { ComponentRegistry } from '../core/registry';
import { StorageManager } from '../core/storage';
interface ComponentRendererProps {
code: string;
scopeId: string;
inline?: boolean;
storage: StorageManager; // Add storage prop
children?: React.ReactNode;
}
interface TransformError {
message: string;
line?: number;
column?: number;
}
const ErrorDisplay: React.FC<{ error: TransformError }> = ({ error }) => {
return (
<div className="react-component-error">
<p>Error in component:</p>
<pre className="error-message">
{error.message}
{error.line && error.column &&
`\nAt line ${error.line}, column ${error.column}`
}
</pre>
</div>
);
};
export const ComponentRenderer: React.FC<ComponentRendererProps> = ({
code,
scopeId,
storage, // Get storage from props
inline = false
}) => {
const [Component, setComponent] = useState<React.ComponentType | null>(null);
const [error, setError] = useState<TransformError | null>(null);
useEffect(() => {
const createComponent = async () => {
try {
const transformedCode = transform(code, {
presets: ['env','react'],
plugins: [
'@babel/plugin-syntax-jsx', // Add JSX syntax plugin explicitly
'@babel/plugin-transform-react-jsx', // Transforms JSX into JS
'@babel/plugin-proposal-class-properties', // Handles class properties
'@babel/plugin-proposal-object-rest-spread', // Handles object spread syntax
],
filename: `component-${scopeId}`,
sourceType: 'module',
configFile: false,
babelrc: false
}).code;
console.log('Transformed Code:', transformedCode);
// Create storage hook bound to this storage instance
const boundUseStorage = <T,>(key: string, defaultValue: T) =>
useStorage(key, defaultValue, storage);
// Enhanced scope with registry and storage
const scope = {
React,
useState: React.useState,
useEffect: React.useEffect,
useRef: React.useRef,
useStorage: boundUseStorage, // Add bound storage hook
...ComponentRegistry,
};
const componentFn = new Function(
...Object.keys(scope),
`try {
${transformedCode}
return Component;
} catch (err) {
console.error('Error in component execution:', err);
throw err;
}`
);
const ComponentType = componentFn(...Object.values(scope));
setComponent(() => ComponentType);
setError(null);
} catch (err) {
console.error('Component transformation failed:', err);
setError({
message: err instanceof Error ? err.message : 'Unknown error',
line: (err as any).loc?.line,
column: (err as any).loc?.column
});
setComponent(null);
}
};
createComponent();
}, [code, scopeId, storage]); // Add storage to dependencies
const Wrapper: React.FC<{ children: React.ReactNode }> =
inline ? ({ children }) => (
<span className="inline-component">{children}</span>
) : ({ children }) => (
<div className="block-component">{children}</div>
);
if (error) {
return <ErrorDisplay error={error} />;
}
if (!Component) {
return null;
}
return (
<Wrapper>
<div
className="component-sandbox"
onClick={e => e.stopPropagation()}
>
<ErrorBoundary>
<Component />
</ErrorBoundary>
</div>
</Wrapper>
);
};

View file

@ -1,36 +0,0 @@
// src/utils/orderBlockVisualization.ts
import { IChartApi, ISeriesApi } from 'lightweight-charts';
import { Rectangle } from '../plugins/RectanglePlugin';
import { OrderBlock } from '../services/OrderBlockAnalysis';
export function visualizeOrderBlocks(
chart: IChartApi,
mainSeries: ISeriesApi<"Candlestick">,
orderBlocks: OrderBlock[]
) {
orderBlocks.forEach(block => {
const rectangle = new Rectangle(
{
time: block.startTime / 1000, // Convert to seconds for lightweight-charts
price: block.highPrice
},
{
time: block.endTime / 1000,
price: block.lowPrice
},
{
fillColor: block.type === 'bullish'
? 'rgba(34, 197, 94, 0.2)' // Green
: 'rgba(239, 68, 68, 0.2)', // Red
borderColor: block.type === 'bullish'
? 'rgba(34, 197, 94, 1)'
: 'rgba(239, 68, 68, 1)',
borderWidth: block.validationMetrics.impulseStrength > 1.5 ? 2 : 1,
opacity: block.validationMetrics.gapQuality,
extend: 'right' // Extend to the right of the chart
}
);
mainSeries.attachPrimitive(rectangle);
});
}

View file

@ -1,92 +0,0 @@
// src/component/RectPlugin.ts
interface Point {
time: number;
price: number;
}
interface RectangleOptions {
fillColor?: string;
borderColor?: string;
borderWidth?: number;
opacity?: number;
extend?: 'none' | 'right';
}
export class Rectangle {
private _point1: Point;
private _point2: Point;
private _options: Required<RectangleOptions>;
private static readonly defaultOptions: Required<RectangleOptions> = {
fillColor: 'rgba(255, 255, 255, 0.2)',
borderColor: 'rgba(255, 255, 255, 1)',
borderWidth: 1,
opacity: 0.2,
extend: 'none'
};
constructor(point1: Point, point2: Point, options: RectangleOptions = {}) {
this._point1 = point1;
this._point2 = point2;
this._options = { ...Rectangle.defaultOptions, ...options };
}
draw(ctx: CanvasRenderingContext2D, pixelRatio: number, model: any): void {
const points = this._calculatePoints(model);
if (!points) return;
const { x1, y1, x2, y2 } = points;
ctx.save();
// Set line style
ctx.lineWidth = this._options.borderWidth * pixelRatio;
ctx.strokeStyle = this._options.borderColor;
ctx.fillStyle = this._options.fillColor;
// Draw rectangle
ctx.beginPath();
ctx.rect(
Math.round(x1 * pixelRatio),
Math.round(y1 * pixelRatio),
Math.round((x2 - x1) * pixelRatio),
Math.round((y2 - y1) * pixelRatio)
);
// Fill with semi-transparency
ctx.globalAlpha = this._options.opacity;
ctx.fill();
// Draw border
ctx.globalAlpha = 1;
ctx.stroke();
ctx.restore();
}
hitTest(x: number, y: number): boolean {
// Implement hit testing if needed
return false;
}
private _calculatePoints(model: any) {
const point1X = model.timeScale().timeToCoordinate(this._point1.time);
const point2X = this._options.extend === 'right'
? model.timeScale().getWidth()
: model.timeScale().timeToCoordinate(this._point2.time);
const point1Y = model.priceScale('right').priceToCoordinate(this._point1.price);
const point2Y = model.priceScale('right').priceToCoordinate(this._point2.price);
if (!point1X || !point2X || !point1Y || !point2Y) {
return null;
}
return {
x1: Math.min(point1X, point2X),
y1: Math.min(point1Y, point2Y),
x2: Math.max(point1X, point2X),
y2: Math.max(point1Y, point2Y)
};
}
}

View file

@ -1,57 +0,0 @@
// components/styled.tsx
import React from 'react';
interface BoxProps extends React.HTMLAttributes<HTMLDivElement> {
mt?: number;
mb?: number;
p?: number;
flex?: boolean;
grid?: boolean;
cols?: number;
gap?: number;
center?: boolean;
}
export const Box = React.forwardRef<HTMLDivElement, BoxProps>(({
mt,
mb,
p,
flex,
grid,
cols,
gap,
center,
style,
...props
}, ref) => {
const computedStyle: React.CSSProperties = {
...(mt && { marginTop: `${mt * 0.25}rem` }),
...(mb && { marginBottom: `${mb * 0.25}rem` }),
...(p && { padding: `${p * 0.25}rem` }),
...(flex && { display: 'flex' }),
...(grid && {
display: 'grid',
gridTemplateColumns: `repeat(${cols || 1}, minmax(0, 1fr))`,
gap: gap ? `${gap * 0.25}rem` : undefined
}),
...(center && {
alignItems: 'center',
justifyContent: 'center'
}),
...style
};
return <div ref={ref} style={computedStyle} {...props} />;
});
// Usage:
const MyComponent = () => {
return (
<Box flex center p={4} mt={2}>
<Box grid cols={2} gap={4}>
<div>Column 1</div>
<div>Column 2</div>
</Box>
</Box>
);
};

View file

@ -1,84 +0,0 @@
// src/core/dynamic.tsx
import React, {
useEffect,
useState,
ComponentType,
FC,
ComponentProps,
ReactElement
} from 'react';
interface DynamicOptions {
loading?: ComponentType;
error?: ComponentType<{ error: Error }>;
}
type ImportFunction<T> = () => Promise<{ default: T }>;
const DefaultError: FC<{ error: Error }> = ({ error }): ReactElement => {
return (
<div className="dynamic-import-error">
Error loading component: {error.message}
</div>
);
};
const DefaultLoading: FC = (): ReactElement => {
return <div className="dynamic-loading">Loading...</div>;
};
export function dynamic<T extends ComponentType<any>>(
importFn: ImportFunction<T>,
options: DynamicOptions = {}
): FC<ComponentProps<T>> {
const LoadingComponent: ComponentType = options.loading || DefaultLoading;
const ErrorComponent: ComponentType<{ error: Error }> = options.error || DefaultError;
const DynamicComponent: FC<ComponentProps<T>> = (props): ReactElement => {
const [Component, setComponent] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let mounted = true;
const loadComponent = async (): Promise<void> => {
try {
const module = await importFn();
if (mounted) {
setComponent(() => module.default);
}
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err : new Error('Failed to load component'));
}
}
};
loadComponent();
return () => { mounted = false; };
}, []);
if (error) {
return <ErrorComponent error={error} />;
}
if (!Component) {
return <LoadingComponent />;
}
return <Component {...props} />;
};
return DynamicComponent;
}
// Version for named exports
export function dynamicNamed<T extends ComponentType<any>>(
importFn: () => Promise<T>,
options: DynamicOptions = {}
): FC<ComponentProps<T>> {
return dynamic(
async () => ({ default: await importFn() }),
options
);
}

View file

@ -1,212 +0,0 @@
// src/core/scope.ts
import React from 'react';
import { App } from 'obsidian';
interface ScopeOptions {
app?: App;
parentScope?: ComponentScope;
isGlobal?: boolean;
}
export class ComponentScope {
private static instances = new Map<string, ComponentScope>();
private static globalScope: ComponentScope | null = null;
private variables = new Map<string, any>();
private children = new Set<ComponentScope>();
private readonly defaultScope: Record<string, any>;
constructor(
private readonly id: string,
private readonly options: ScopeOptions = {}
) {
// Initialize default React scope
this.defaultScope = {
React,
useState: React.useState,
useEffect: React.useEffect,
useRef: React.useRef,
useMemo: React.useMemo,
useCallback: React.useCallback,
useContext: React.useContext,
// Add Obsidian app if available
app: options.app,
};
if (options.isGlobal) {
ComponentScope.globalScope = this;
} else if (options.parentScope) {
options.parentScope.addChild(this);
}
ComponentScope.instances.set(id, this);
}
// Get a value from scope
get(name: string): any {
// Check local variables first
if (this.variables.has(name)) {
return this.variables.get(name);
}
// Check default scope
if (name in this.defaultScope) {
return this.defaultScope[name];
}
// Check parent scope
if (this.options.parentScope) {
return this.options.parentScope.get(name);
}
// Check global scope as last resort
if (!this.options.isGlobal && ComponentScope.globalScope) {
return ComponentScope.globalScope.get(name);
}
return undefined;
}
// Set a value in scope
set(name: string, value: any): void {
this.variables.set(name, value);
}
// Remove a value from scope
delete(name: string): boolean {
return this.variables.delete(name);
}
// Check if scope has a variable
has(name: string): boolean {
return (
this.variables.has(name) ||
name in this.defaultScope ||
(this.options.parentScope?.has(name) ?? false) ||
(!this.options.isGlobal && (ComponentScope.globalScope?.has(name) ?? false))
);
}
// Add a child scope
private addChild(scope: ComponentScope): void {
this.children.add(scope);
}
// Remove a child scope
private removeChild(scope: ComponentScope): void {
this.children.delete(scope);
}
// Clear all variables in this scope
clear(): void {
this.variables.clear();
}
// Dispose of this scope
dispose(): void {
if (this.options.parentScope) {
this.options.parentScope.removeChild(this);
}
ComponentScope.instances.delete(this.id);
this.clear();
// Dispose of all children
this.children.forEach(child => child.dispose());
this.children.clear();
}
// Get all variables in this scope (including inherited)
getAllVariables(): Record<string, any> {
const variables: Record<string, any> = {
...this.defaultScope,
...Object.fromEntries(this.variables)
};
if (this.options.parentScope) {
return {
...this.options.parentScope.getAllVariables(),
...variables
};
}
return variables;
}
// Static methods for scope management
static getScope(id: string): ComponentScope | undefined {
return this.instances.get(id);
}
static createScope(id: string, options: ScopeOptions = {}): ComponentScope {
if (this.instances.has(id)) {
throw new Error(`Scope with id "${id}" already exists`);
}
return new ComponentScope(id, options);
}
static getOrCreateScope(id: string, options: ScopeOptions = {}): ComponentScope {
return this.instances.get(id) || new ComponentScope(id, options);
}
static disposeScope(id: string): void {
const scope = this.instances.get(id);
if (scope) {
scope.dispose();
}
}
// Create a hook for React components to access scope
static createScopeHook(scopeId: string) {
return function useScopeValue(name: string) {
const scope = ComponentScope.getScope(scopeId);
if (!scope) {
throw new Error(`Scope "${scopeId}" not found`);
}
return scope.get(name);
};
}
}
// Helper function to create a new component scope
export function createComponentScope(
id: string,
app?: App,
parentScope?: ComponentScope
): ComponentScope {
return new ComponentScope(id, { app, parentScope });
}
// React hook for accessing scope
export function useComponentScope(scopeId: string) {
return React.useMemo(() => {
const scope = ComponentScope.getScope(scopeId);
if (!scope) {
throw new Error(`Scope "${scopeId}" not found`);
}
return scope;
}, [scopeId]);
}
// Example usage:
/*
// Create a global scope
const globalScope = new ComponentScope('global', { isGlobal: true });
globalScope.set('sharedValue', 42);
// Create a component scope
const componentScope = createComponentScope('component-1', app);
componentScope.set('localValue', 'hello');
// In a React component:
function MyComponent() {
const scope = useComponentScope('component-1');
const localValue = scope.get('localValue');
const sharedValue = scope.get('sharedValue');
return (
<div>
Local: {localValue}, Shared: {sharedValue}
</div>
);
}
*/

View file

@ -1,34 +0,0 @@
// src/core/tailwind.ts
import postcss from 'postcss';
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
export class TailwindProcessor {
private config: any;
constructor() {
this.config = {
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {},
},
// Disable preflight to avoid conflicts with Obsidian
corePlugins: {
preflight: false
}
};
}
// Process Tailwind CSS
async process(css: string): Promise<string> {
const result = await postcss([
tailwindcss(this.config),
autoprefixer
]).process(css, {
from: undefined
});
return result.css;
}
}

View file

@ -1,98 +0,0 @@
// src/hooks/useMarketData.tsx
import { useState, useEffect } from 'react';
import { TFile } from 'obsidian';
import { StorageManager } from '../core/storage';
import { MarketDataService } from 'src/services/marketDataService';
import { MarketData, MarketDataCache } from '../services/marketDataService';
interface UseMarketDataResult {
data: MarketData[];
loading: boolean;
error: Error | null;
}
export function useMarketData(
symbol: string,
interval: string,
range: string,
storage: StorageManager,
noteFile: TFile
) {
const [data, setData] = useState<MarketData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const marketData = await storage.getMarketData(
symbol,
interval,
range,
noteFile
);
setData(marketData);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setLoading(false);
}
};
fetchData();
}, [symbol, interval, range, noteFile, storage]);
return { data, loading, error };
}
export function createMarketDataHook(storage: StorageManager, noteFile: TFile) {
return function useMarketData(
symbol: string,
interval: string,
range: string
): UseMarketDataResult {
const [data, setData] = useState<MarketData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const cacheKey = `market-data-${symbol}-${interval}-${range}`;
// Try to get from storage first
const cached = await storage.get<MarketDataCache | null>(
cacheKey,
null,
noteFile
);
if (cached && Date.now() - cached.timestamp < 24 * 60 * 60 * 1000) {
setData(cached.data);
return;
}
// Fetch fresh data
const {data:freshData} = await MarketDataService.fetchHistoricalData(symbol);
// Cache the new data
await storage.set(
cacheKey,
{ data: freshData, timestamp: Date.now() },
noteFile
);
setData(freshData);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setLoading(false);
}
};
fetchData();
}, [symbol, interval, range]);
return { data, loading, error };
};
}

View file

@ -1,793 +0,0 @@
// src/services/OrderBlockAnalysis.ts
import { TFile } from 'obsidian';
import { MarketDataService, MarketData } from './marketDataService';
import { MarketDataStorage } from './marketDataStorage';
interface SwingPoint {
time: number;
price: number;
type: 'high' | 'low';
index: number;
}
interface TrendLeg {
startIndex: number;
endIndex: number;
startTime: number;
endTime: number;
direction: 'up' | 'down' | 'sideways';
strength: number;
startPrice: number;
endPrice: number;
swingPoints: SwingPoint[];
}
interface OrderBlock {
id: string;
type: 'bullish' | 'bearish';
startTime: number;
endTime: number;
highPrice: number;
lowPrice: number;
volume: number;
impulseMagnitude: number;
priceValueGap: number;
validationMetrics: {
trendAlignment: boolean;
hasBreakOfStructure: boolean;
gapQuality: number; // 0-1 score
impulseStrength: number; // Relative to average movement
};
}
interface AnalysisResult {
symbol: string;
timeframe: string;
analysisTime: number;
marketHours: {
start: number;
end: number;
};
marketData: MarketData[]; // Add this
trendLegs: TrendLeg[]; // Add this
trend: {
direction: 'up' | 'down' | 'none';
strength: number;
swingPoints: SwingPoint[];
};
orderBlocks: OrderBlock[];
}
export class OrderBlockAnalysisService {
private static readonly MARKET_HOURS = {
start: { hour: 9, minute: 30 },
end: { hour: 16, minute: 0 }
};
// Analysis specific params
private static readonly ANALYSIS_PARAMS = {
minSwingPoints: 4,
lookbackPeriods: 20,
minImpulseStrength: 1.5,
minGapSize: 0.1, // 10% of average candle size
trendStrengthThreshold: 0.6
};
private static readonly TREND_PARAMS = {
minSwingPoints: 2, // Reduced minimum swing points
trendThreshold: 0.4, // Lowered threshold
minMovementSize: 0.1, // Minimum % move
swingPointConfirmation: 1, // Candles to confirm swing
momentumThreshold: 0.6, // New: Momentum strength threshold
priceChangeThreshold: 0.001 // New: Minimum price change (0.1%)
};
constructor(
private storage: MarketDataStorage,
private marketDataService: MarketDataService
) {}
/**
* Main analysis function for a given symbol and timeframe
*/
async analyzeOrderBlocks(
symbol: string,
timeframe: string,
noteFile: TFile
): Promise<AnalysisResult> {
try{
const today = new Date();
const startTime = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate()-1,
OrderBlockAnalysisService.MARKET_HOURS.start.hour,
OrderBlockAnalysisService.MARKET_HOURS.start.minute
).getTime();
const endTime = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate()-1,
OrderBlockAnalysisService.MARKET_HOURS.end.hour,
OrderBlockAnalysisService.MARKET_HOURS.end.minute
).getTime();
const config = {
interval: timeframe as any,
startTime,
endTime
};
// Use the marketDataService instance for fetching
const data = await MarketDataService.getMarketData(symbol, config, this.storage);
// Apply market hours filter
const marketHoursData = this.filterMarketHours(data);
const trend = this.analyzeTrend(marketHoursData);
const orderBlocks = this.findOrderBlocks(marketHoursData, trend);
return {
symbol,
timeframe,
analysisTime: Date.now(),
marketHours: {
start: startTime,
end: endTime
},
marketData: marketHoursData,
trendLegs: this.analyzeTrendLegs(marketHoursData),
trend,
orderBlocks
};
} catch (error) {
console.error('Error analyzing order blocks:', error);
throw error;
}
}
/**
* Filter data for market hours only
*/
private filterMarketHours(data: MarketData[]): MarketData[] {
return data.filter(candle => {
const date = new Date(candle.time);
const hours = date.getHours();
const minutes = date.getMinutes();
if (hours < OrderBlockAnalysisService.MARKET_HOURS.start.hour ||
hours > OrderBlockAnalysisService.MARKET_HOURS.end.hour) return false;
if (hours === OrderBlockAnalysisService.MARKET_HOURS.start.hour &&
minutes < OrderBlockAnalysisService.MARKET_HOURS.start.minute) return false;
if (hours === OrderBlockAnalysisService.MARKET_HOURS.end.hour &&
minutes >= OrderBlockAnalysisService.MARKET_HOURS.end.minute) return false;
return true;
});
}
/**
* Enhanced trend analysis that considers both structure and momentum
*/
private analyzeTrend(data: MarketData[]): {
direction: 'up' | 'down' | 'none';
strength: number;
swingPoints: SwingPoint[];
} {
if (data.length < 10) return { direction: 'none', strength: 0, swingPoints: [] };
const atr = this.calculateATR(data, 14);
const swingPoints = this.findSignificantSwings(data, atr);
// Calculate overall momentum
const momentum = this.calculateMomentum(data);
// Calculate price movement
const priceChange = (data[data.length - 1].close - data[0].close) / data[0].close;
const absolutePriceChange = Math.abs(priceChange);
// Only proceed with detailed analysis if we have significant price movement
if (absolutePriceChange < OrderBlockAnalysisService.TREND_PARAMS.priceChangeThreshold) {
return { direction: 'none', strength: 0, swingPoints };
}
// Get structural analysis
const structure = this.analyzeTrendStructure(swingPoints, atr);
// Combine structural and momentum analysis
const direction = this.determineOverallTrend(structure, momentum, priceChange);
const strength = this.calculateOverallStrength(structure.strength, momentum.strength);
return {
direction,
strength,
swingPoints
};
}
/**
* Calculate Average True Range
*/
private calculateATR(data: MarketData[], period: number): number {
if (data.length < period) return 0;
let tr = [];
for (let i = 1; i < data.length; i++) {
const high = data[i].high;
const low = data[i].low;
const prevClose = data[i-1].close;
tr.push(Math.max(
high - low,
Math.abs(high - prevClose),
Math.abs(low - prevClose)
));
}
// Calculate simple moving average of TR
const atr = tr.slice(-period).reduce((sum, val) => sum + val, 0) / period;
return atr;
}
/**
* Calculate price momentum
*/
private calculateMomentum(data: MarketData[]): { direction: 'up' | 'down'; strength: number } {
const closes = data.map(d => d.close);
let upMoves = 0;
let downMoves = 0;
// Count consecutive moves
for (let i = 1; i < closes.length; i++) {
if (closes[i] > closes[i - 1]) upMoves++;
if (closes[i] < closes[i - 1]) downMoves++;
}
const totalMoves = upMoves + downMoves;
const upStrength = upMoves / totalMoves;
const downStrength = downMoves / totalMoves;
return {
direction: upStrength > downStrength ? 'up' : 'down',
strength: Math.max(upStrength, downStrength)
};
}
/**
* Determine overall trend combining structure and momentum
*/
private determineOverallTrend(
structure: { direction: 'up' | 'down' | 'none'; strength: number },
momentum: { direction: 'up' | 'down'; strength: number },
priceChange: number
): 'up' | 'down' | 'none' {
// Strong momentum overrides structure
if (momentum.strength > OrderBlockAnalysisService.TREND_PARAMS.momentumThreshold) {
return momentum.direction;
}
// Strong structure with confirming price change
if (structure.direction !== 'none' &&
Math.sign(priceChange) === (structure.direction === 'up' ? 1 : -1)) {
return structure.direction;
}
// Default to momentum direction if price change confirms it
if (Math.sign(priceChange) === (momentum.direction === 'up' ? 1 : -1)) {
return momentum.direction;
}
return 'none';
}
/**
* Calculate overall trend strength
*/
private calculateOverallStrength(structureStrength: number, momentumStrength: number): number {
return (structureStrength * 0.6 + momentumStrength * 0.4);
}
/**
* Find significant swing points using ATR for context
*/
private findSignificantSwings(data: MarketData[], atr: number): SwingPoint[] {
const swingPoints: SwingPoint[] = [];
const minMove = atr * 0.3; // Reduced from 0.5 to catch more potential swings
for (let i = 2; i < data.length - 2; i++) {
const current = data[i];
const before = data.slice(i - 2, i);
const after = data.slice(i + 1, i + 3);
// Check for swing high with relaxed conditions
if (before.every(c => c.high <= current.high) &&
after[0].high < current.high) {
swingPoints.push({
type: 'high',
price: current.high,
time: current.time,
index: i
});
}
// Check for swing low with relaxed conditions
if (before.every(c => c.low >= current.low) &&
after[0].low > current.low) {
swingPoints.push({
type: 'low',
price: current.low,
time: current.time,
index: i
});
}
}
return swingPoints;
}
/**
* Analyze trend structure using swing points
*/
private analyzeTrendStructure(
swingPoints: SwingPoint[],
atr: number
): { direction: 'up' | 'down' | 'none'; strength: number; } {
const highs = swingPoints.filter(p => p.type === 'high')
.sort((a, b) => a.time - b.time);
const lows = swingPoints.filter(p => p.type === 'low')
.sort((a, b) => a.time - b.time);
if (highs.length < 2 || lows.length < 2) {
return { direction: 'none', strength: 0 };
}
// Calculate trend metrics
const hhSequence = this.calculateSequenceStrength(highs, 'up', atr);
const lhSequence = this.calculateSequenceStrength(highs, 'down', atr);
const hlSequence = this.calculateSequenceStrength(lows, 'up', atr);
const llSequence = this.calculateSequenceStrength(lows, 'down', atr);
// Determine trend
const upStrength = (hhSequence + hlSequence) / 2;
const downStrength = (lhSequence + llSequence) / 2;
if (upStrength > OrderBlockAnalysisService.TREND_PARAMS.trendThreshold &&
upStrength > downStrength) {
return {
direction: 'up',
strength: upStrength
};
}
if (downStrength > OrderBlockAnalysisService.TREND_PARAMS.trendThreshold &&
downStrength > upStrength) {
return {
direction: 'down',
strength: downStrength
};
}
return { direction: 'none', strength: 0 };
}
private analyzeTrendLegs(data: MarketData[]): TrendLeg[] {
const trendLegs: TrendLeg[] = [];
const atr = this.calculateATR(data, 14);
let currentLegStart = 0;
// Minimum number of candles to consider a valid trend leg
const MIN_LEG_LENGTH = 5;
// Loop through data to identify trend changes
for (let i = MIN_LEG_LENGTH; i < data.length - MIN_LEG_LENGTH; i++) {
const currentSegment = data.slice(currentLegStart, i + 1);
const nextSegment = data.slice(i - MIN_LEG_LENGTH, i + MIN_LEG_LENGTH);
// Check for trend change
if (this.isTrendChange(currentSegment, nextSegment, atr)) {
// Add completed trend leg
if (i - currentLegStart >= MIN_LEG_LENGTH) {
const legData = data.slice(currentLegStart, i);
const trendLeg = this.analyzeSingleTrendLeg(legData, currentLegStart, i);
trendLegs.push(trendLeg);
}
currentLegStart = i;
}
}
// Add final trend leg
if (data.length - currentLegStart >= MIN_LEG_LENGTH) {
const finalLegData = data.slice(currentLegStart);
const finalTrendLeg = this.analyzeSingleTrendLeg(
finalLegData,
currentLegStart,
data.length - 1
);
trendLegs.push(finalTrendLeg);
}
return trendLegs;
}
private isTrendChange(
currentSegment: MarketData[],
nextSegment: MarketData[],
atr: number
): boolean {
const currentDirection = this.determineTrendDirection(currentSegment);
const nextDirection = this.determineTrendDirection(nextSegment);
// Check for direction change
if (currentDirection !== nextDirection) {
// Verify change is significant (> 1 ATR)
const priceChange = Math.abs(
nextSegment[nextSegment.length - 1].close - nextSegment[0].close
);
return priceChange > atr;
}
return false;
}
private analyzeSingleTrendLeg(
data: MarketData[],
startIndex: number,
endIndex: number
): TrendLeg {
const swingPoints = this.findSignificantSwings(data, this.calculateATR(data, 14));
const priceChange = (data[data.length - 1].close - data[0].close) / data[0].close;
const momentum = this.calculateMomentum(data);
// Determine trend direction
let direction: 'up' | 'down' | 'sideways';
if (Math.abs(priceChange) < OrderBlockAnalysisService.TREND_PARAMS.priceChangeThreshold) {
direction = 'sideways';
} else {
direction = priceChange > 0 ? 'up' : 'down';
}
// Calculate trend strength based on:
// 1. Price change magnitude
// 2. Momentum consistency
// 3. Swing point alignment
const priceStrength = Math.min(Math.abs(priceChange) * 10, 1); // Scale price change
const momentumStrength = momentum.strength;
const swingStrength = this.calculateSwingAlignment(swingPoints, direction);
const strength = (
priceStrength * 0.4 +
momentumStrength * 0.4 +
swingStrength * 0.2
) * 100; // Convert to percentage
return {
startIndex,
endIndex,
startTime: data[0].time,
endTime: data[data.length - 1].time,
direction,
strength,
startPrice: data[0].close,
endPrice: data[data.length - 1].close,
swingPoints
};
}
private calculateSwingAlignment(
swingPoints: SwingPoint[],
direction: 'up' | 'down' | 'sideways'
): number {
if (direction === 'sideways' || swingPoints.length < 2) return 0;
let aligned = 0;
let total = 0;
for (let i = 1; i < swingPoints.length; i++) {
if (direction === 'up') {
if (swingPoints[i].price > swingPoints[i - 1].price) aligned++;
} else {
if (swingPoints[i].price < swingPoints[i - 1].price) aligned++;
}
total++;
}
return total > 0 ? aligned / total : 0;
}
private determineTrendDirection(data: MarketData[]): 'up' | 'down' | 'sideways' {
const priceChange = (data[data.length - 1].close - data[0].close) / data[0].close;
if (Math.abs(priceChange) < OrderBlockAnalysisService.TREND_PARAMS.priceChangeThreshold) {
return 'sideways';
}
return priceChange > 0 ? 'up' : 'down';
}
/**
* Calculate sequence strength relative to ATR
*/
private calculateSequenceStrength(
points: SwingPoint[],
direction: 'up' | 'down',
atr: number
): number {
if (points.length < 2) return 0;
let validMoves = 0;
let totalMoves = 0;
for (let i = 1; i < points.length; i++) {
const move = direction === 'up'
? points[i].price - points[i-1].price
: points[i-1].price - points[i].price;
if (move > 0 && Math.abs(move) > atr * 0.5) {
validMoves++;
}
totalMoves++;
}
return totalMoves > 0 ? validMoves / totalMoves : 0;
}
/**
* Calculate trend strength (0-1)
*/
private calculateTrendStrength(
points: SwingPoint[],
direction: 'up' | 'down'
): number {
if (points.length < 2) return 0;
let increasing = 0;
let total = 0;
for (let i = 1; i < points.length; i++) {
const current = points[i].price;
const previous = points[i - 1].price;
if (direction === 'up' && current > previous) increasing++;
if (direction === 'down' && current < previous) increasing++;
total++;
}
return total > 0 ? increasing / total : 0;
}
/**
* Find order blocks
*/
private findOrderBlocks(
data: MarketData[],
trend: { direction: 'up' | 'down' | 'none'; strength: number; }
): OrderBlock[] {
const orderBlocks: OrderBlock[] = [];
// Only proceed if we have a clear trend
if (trend.direction === 'none' || trend.strength < OrderBlockAnalysisService.ANALYSIS_PARAMS.trendStrengthThreshold) {
return orderBlocks;
}
// Use a sliding window of 7 candles (3 before, current, 3 after)
for (let i = 3; i < data.length - 3; i++) {
const window = {
before: data.slice(i - 3, i),
current: data[i],
after: data.slice(i + 1, i + 4)
};
// Check for potential order block
const orderBlock = this.validateOrderBlock(window, trend.direction);
if (orderBlock) {
orderBlocks.push(orderBlock);
}
}
return this.filterOverlappingBlocks(orderBlocks);
}
/**
* Validate potential order block
*/
private validateOrderBlock(
window: {
before: MarketData[];
current: MarketData;
after: MarketData[];
},
trendDirection: 'up' | 'down'
): OrderBlock | null {
const { before, current, after } = window;
// Check for bullish order block in uptrend
if (trendDirection === 'up') {
const isBearishCandle = current.close < current.open;
const hasImpulsiveMove = this.hasImpulsiveMove(after, 'up');
const pvg = this.calculatePriceValueGap([...before, current, ...after]);
if (isBearishCandle && hasImpulsiveMove && pvg > OrderBlockAnalysisService.ANALYSIS_PARAMS.minGapSize) {
return {
id: `OB_${current.time}`,
type: 'bullish',
startTime: current.time,
endTime: after[0].time,
highPrice: current.high,
lowPrice: current.low,
volume: current.volume,
impulseMagnitude: this.calculateImpulseMagnitude([...before, current, ...after]),
priceValueGap: pvg,
validationMetrics: {
trendAlignment: true,
hasBreakOfStructure: this.hasBreakOfStructure([...before, current, ...after], 'up'),
gapQuality: this.calculateGapQuality(pvg, current),
impulseStrength: this.calculateImpulseStrength(after, before)
}
};
}
}
// Check for bearish order block in downtrend
if (trendDirection === 'down') {
const isBullishCandle = current.close > current.open;
const hasImpulsiveMove = this.hasImpulsiveMove(after, 'down');
const pvg = this.calculatePriceValueGap([...before, current, ...after]);
if (isBullishCandle && hasImpulsiveMove && pvg > OrderBlockAnalysisService.ANALYSIS_PARAMS.minGapSize) {
return {
id: `OB_${current.time}`,
type: 'bearish',
startTime: current.time,
endTime: after[0].time,
highPrice: current.high,
lowPrice: current.low,
volume: current.volume,
impulseMagnitude: this.calculateImpulseMagnitude([...before, current, ...after]),
priceValueGap: pvg,
validationMetrics: {
trendAlignment: true,
hasBreakOfStructure: this.hasBreakOfStructure([...before, current, ...after], 'down'),
gapQuality: this.calculateGapQuality(pvg, current),
impulseStrength: this.calculateImpulseStrength(after, before)
}
};
}
}
return null;
}
/**
* Calculate price value gap
*/
private calculatePriceValueGap(candles: MarketData[]): number {
let maxGap = 0;
for (let i = 1; i < candles.length - 1; i++) {
const current = candles[i];
const next = candles[i + 1];
// Check for gap between candles
if (next.low > current.high) {
maxGap = Math.max(maxGap, next.low - current.high);
}
if (next.high < current.low) {
maxGap = Math.max(maxGap, current.low - next.high);
}
}
return maxGap;
}
/**
* Check for impulsive move
*/
private hasImpulsiveMove(candles: MarketData[], direction: 'up' | 'down'): boolean {
const moves = candles.map(c => Math.abs(c.close - c.open));
const avgMove = moves.reduce((sum, move) => sum + move, 0) / moves.length;
if (direction === 'up') {
return candles[0].close > candles[0].open &&
avgMove > OrderBlockAnalysisService.ANALYSIS_PARAMS.minImpulseStrength;
} else {
return candles[0].close < candles[0].open &&
avgMove > OrderBlockAnalysisService.ANALYSIS_PARAMS.minImpulseStrength;
}
}
/**
* Calculate impulse magnitude
*/
private calculateImpulseMagnitude(candles: MarketData[]): number {
const moves = candles.map(c => ({
move: Math.abs(c.close - c.open),
volume: c.volume
}));
// Volume-weighted average move
const weightedMoves = moves.map(m => m.move * (m.volume / Math.max(...moves.map(x => x.volume))));
return weightedMoves.reduce((sum, move) => sum + move, 0) / weightedMoves.length;
}
/**
* Calculate gap quality (0-1)
*/
private calculateGapQuality(gap: number, candle: MarketData): number {
const candleSize = Math.abs(candle.high - candle.low);
return Math.min(gap / candleSize, 1);
}
/**
* Calculate impulse strength
*/
private calculateImpulseStrength(after: MarketData[], before: MarketData[]): number {
const afterMoves = after.map(c => Math.abs(c.close - c.open));
const beforeMoves = before.map(c => Math.abs(c.close - c.open));
const avgAfter = afterMoves.reduce((sum, move) => sum + move, 0) / afterMoves.length;
const avgBefore = beforeMoves.reduce((sum, move) => sum + move, 0) / beforeMoves.length;
return avgAfter / avgBefore;
}
/**
* Check for break of structure
*/
private hasBreakOfStructure(candles: MarketData[], direction: 'up' | 'down'): boolean {
const middle = Math.floor(candles.length / 2);
const before = candles.slice(0, middle);
const after = candles.slice(middle);
if (direction === 'up') {
const beforeHigh = Math.max(...before.map(c => c.high));
const afterHigh = Math.max(...after.map(c => c.high));
return afterHigh > beforeHigh;
} else {
const beforeLow = Math.min(...before.map(c => c.low));
const afterLow = Math.min(...after.map(c => c.low));
return afterLow < beforeLow;
}
}
/**
* Filter overlapping order blocks
*/
private filterOverlappingBlocks(blocks: OrderBlock[]): OrderBlock[] {
return blocks.filter((block, index) => {
// Check if this block overlaps with any stronger blocks
const hasStrongerOverlap = blocks.some((otherBlock, otherIndex) => {
if (index === otherIndex) return false;
const timeOverlap = block.startTime <= otherBlock.endTime &&
block.endTime >= otherBlock.startTime;
const priceOverlap = block.lowPrice <= otherBlock.highPrice &&
block.highPrice >= otherBlock.lowPrice;
return timeOverlap && priceOverlap &&
otherBlock.validationMetrics.impulseStrength >
block.validationMetrics.impulseStrength;
});
return !hasStrongerOverlap;
});
}
private getTodayMarketOpen(): number {
const now = new Date();
return new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
OrderBlockAnalysisService.MARKET_HOURS.start.hour,
OrderBlockAnalysisService.MARKET_HOURS.start.minute
).getTime();
}
private getTodayMarketClose(): number {
const now = new Date();
return new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
OrderBlockAnalysisService.MARKET_HOURS.end.hour,
OrderBlockAnalysisService.MARKET_HOURS.end.minute
).getTime();
}
}
export type { AnalysisResult, OrderBlock, SwingPoint };

View file

@ -1,459 +0,0 @@
// src/services/marketDataService.ts
import { error, time } from "console";
import { MarketDataStorage } from "./marketDataStorage";
import { TFile } from "obsidian";
import dotenv from 'dotenv';
// Initialize dotenv
dotenv.config();
// Create a config object to manage environment variables
const config = {
primaryApiKey: process.env.ALPHA_VANTAGE_PRIMARY_KEY,
secondaryApiKey: process.env.ALPHA_VANTAGE_SECONDARY_KEY,
cacheDuration: parseInt(process.env.MARKET_DATA_CACHE_DURATION || '86400000'),
timezone: process.env.TIMEZONE || 'US/Eastern',
// Validate environment variables
validate() {
if (!this.primaryApiKey) {
throw new Error('ALPHA_VANTAGE_PRIMARY_KEY is not set in environment variables');
}
if (!this.secondaryApiKey) {
throw new Error('ALPHA_VANTAGE_SECONDARY_KEY is not set in environment variables');
}
}
};
export interface MarketData {
time: number;
open: number;
high: number;
low: number;
close: number;
volume: number;
sma?: number; // Simple Moving Average
ema?: number; // Exponential Moving Average
rsi?: number; // Relative Strength Index
}
export interface MarketDataCache {
data: MarketData[];
timestamp: number;
}
export interface MarketHours {
start: number; // 4:00 AM ET
end: number; // 19:59 PM ET
timeZone: string; // 'America/New_York'
}
export type IntervalType = '1min' | '5min' | '15min' | '30min' | '60min' | 'daily';
export interface TimeframeConfig {
interval: IntervalType;
startTime: number;
endTime: number;
}
export interface CacheEntry {
data: MarketData[];
timestamp: number;
interval: IntervalType;
startTime: number;
endTime: number;
timezone:string;
}
export interface CacheMetadata {
[symbol: string]: {
[interval in IntervalType]?: CacheEntry[];
};
}
export class MarketDataService {
static async fetchHistoricalData(
symbol: string,
outputsize: 'full' | 'compact' = 'full'
): Promise<{data:MarketData[],timezone: string}> {
// Validate environment variables
config.validate();
const url = `https://www.alphavantage.co/query?function=TIME_SERIES_DAILY&symbol=${symbol}&outputsize=${outputsize}&apikey=${config.primaryApiKey}`;
try {
const response = await fetch(url);
const data = await response.json();
// Check for API errors
if (data['Error Message']) {
throw new Error(data['Error Message']);
}
const timezone = data['Meta Data']['6. Time Zone'];
const timeSeries = data['Time Series (Daily)'];
const marketData= Object.entries(timeSeries).map(([date, values]: [string, any]) => ({
time: new Date(date).getTime(),
open: parseFloat(values['1. open']),
high: parseFloat(values['2. high']),
low: parseFloat(values['3. low']),
close: parseFloat(values['4. close']),
volume: parseInt(values['5. volume'])
})).reverse();
return{data:marketData,timezone}
} catch (error) {
console.error('Error fetching historical data:', error);
throw error;
}
}
static async fetchIntraday(
symbol: string,
interval: '1min' | '5min' | '15min' | '30min' | '60min' = '5min'
): Promise<{data: MarketData[], timezone: string }> {
// Validate environment variables
config.validate();
const url = `https://www.alphavantage.co/query?function=TIME_SERIES_INTRADAY&symbol=${symbol}&interval=${interval}&outputsize=full&apikey=${config.secondaryApiKey}`;
try {
const response = await fetch(url);
const data = await response.json();
// Check for rate limit message
/* if (data.Information && data.Information.includes('rate limit')) {
throw new Error('API rate limit reached. Please try again in a minute.');
}*/
// Log full response to see structure
console.log('Alpha Vantage Response:', data);
// Method 1: Log all top-level keys
console.log('Top level keys:', Object.keys(data));
const timezone = data['Meta Data']['5. Time Zone'];
const timeSeriesKey = `Time Series (${interval})`;
const timeSeries = data[timeSeriesKey];
if (!timeSeries) {
throw new Error('No data returned from API. You may have exceeded the daily limit.');
}
const marketData= Object.entries(timeSeries).map(([date, values]: [string, any]) => ({
time: new Date(date).getTime(),
open: parseFloat(values['1. open']),
high: parseFloat(values['2. high']),
low: parseFloat(values['3. low']),
close: parseFloat(values['4. close']),
volume: parseInt(values['5. volume'])
})).reverse();
return { data: marketData, timezone}
} catch (error) {
console.error('Error fetching intraday data:', error);
throw error;
//For when we want to hide implementation details
//throw new Error('Unable to fetch intraday data. Please try again later.');
}
}
static readonly MARKET_HOURS: MarketHours = {
start: 4 * 60, // 4:00 AM in minutes
end: 20 * 60 - 1, // 19:59 PM in minutes
timeZone: config.timezone
};
// Add method for getting multiple timeframes in one call
static async fetchMultiTimeframe(
symbol: string,
timeframes: Array<'1min' | '5min' | '15min' | '30min' | '60min' | 'daily'>
): Promise<Record<string, MarketData[]>> {
try {
const results: Record<string, MarketData[]> = {};
await Promise.all(
timeframes.map(async (timeframe) => {
if (timeframe === 'daily') {
results[timeframe] = (await this.fetchHistoricalData(symbol)).data;
} else {
data:results[timeframe] = (await this.fetchIntraday(symbol, timeframe)).data;
}
})
);
return results;
} catch (error) {
console.error('Error fetching multiple timeframes:', error);
throw error;
}
}
private static INTERVALS_MINUTES = {
'1min': 1,
'5min': 5,
'15min': 15,
'30min': 30,
'60min': 60,
'daily': 1440
};
private static adjustTimeEndRequest(timestamp: number): number {
const date = new Date(timestamp);
const day = date.getDay(); // 0 = Sunday, 6 = Saturday
const hours = date.getHours();
const minutes = date.getMinutes();
// If weekend, adjust to Friday
if (day === 1&&hours<9) {date.setDate(date.getDate() - 3);date.setHours(19, 59, 0, 0);} // Monday -> Friday
if (day === 0) {date.setDate(date.getDate() - 2); date.setHours(19, 59, 0, 0);} // Sunday -> Friday
if (day === 6) {date.setDate(date.getDate() - 1);date.setHours(19, 59, 0, 0);} // Saturday -> Friday
// Set to 16:00 (4 PM) ET if after market close
if (hours > 19|| hours<4 ) {
date.setHours(19, 59, 0, 0);
date.setDate(date.getDate() - hours<4?1:0);
}
return date.getTime();
}
private static doesCacheCover(cache: CacheEntry, request: TimeframeConfig): boolean {
// If cache's last data point is within 24 hours, consider it current
const adjustedEndTime = this.adjustTimeEndRequest(
cache.endTime +24*60*60*1000>= request.endTime ? cache.endTime : request.endTime
);
console.log('Request Time:', {
Time: new Date(adjustedEndTime).toLocaleString()
});
return cache.interval === request.interval &&
cache.startTime <= request.startTime &&
cache.endTime >= adjustedEndTime;
}
private static aggregateToHigherTimeframe(
data: MarketData[],
fromInterval: IntervalType,
toInterval: IntervalType
): MarketData[] {
const fromMinutes = this.INTERVALS_MINUTES[fromInterval];
const toMinutes = this.INTERVALS_MINUTES[toInterval];
const barsPerPeriod = toMinutes / fromMinutes;
// Group data into higher timeframe periods
const groupedData: { [key: number]: MarketData[] } = {};
data.forEach(bar => {
const periodStart = Math.floor(bar.time / (toMinutes * 60 * 1000)) * (toMinutes * 60 * 1000);
if (!groupedData[periodStart]) {
groupedData[periodStart] = [];
}
groupedData[periodStart].push(bar);
});
// Aggregate each period
return Object.entries(groupedData)
.filter(([_, bars]) => bars.length >= barsPerPeriod * 0.7) // Require at least 70% of bars
.map(([time, bars]) => ({
time: parseInt(time),
open: bars[0].open,
high: Math.max(...bars.map(b => b.high)),
low: Math.min(...bars.map(b => b.low)),
close: bars[bars.length - 1].close,
volume: bars.reduce((sum, b) => sum + b.volume, 0)
}));
}
static addMarketTime(time: number, minutesToAdd: number): number {
let date = new Date(time);
let currentMinutes = date.getHours() * 60 + date.getMinutes();
let newMinutes = currentMinutes + minutesToAdd;
// If would exceed market close
if (newMinutes > this.MARKET_HOURS.end) {
// Move to next day at market open
date.setDate(date.getDate() + 1);
date.setHours(this.MARKET_HOURS.start/60, 0, 0, 0);
// If weekend, move to Monday
while (date.getDay() === 0 || date.getDay() === 6) {
date.setDate(date.getDate() + 1);
}
} else {
// Stay on same day, just update time
date.setHours(Math.floor(newMinutes / 60), newMinutes % 60, 0, 0);
}
return date.getTime();
}
static async getMarketData(
symbol: string,
config: TimeframeConfig,
storage: MarketDataStorage
): Promise<MarketData[]> {
console.log('Request Config:', {
symbol,
interval: config.interval,
startTime: new Date(config.startTime).toLocaleString(),
endTime: new Date(config.endTime).toLocaleString()
});
try{
// Get all cache entries for this symbol/interval
const cacheEntries = await storage.getMarketData(symbol, config.interval);
console.log(`Found ${cacheEntries.length} cache entries`);
//console.log(`Checking cache for ${symbol} ${config.interval} data...`);
const overlappingEntries = cacheEntries.filter(cache =>
// Entry ends after request starts AND entry starts before request ends
this.addMarketTime(cache.endTime,1) >= config.startTime && cache.startTime <= this.addMarketTime(config.endTime,1)
);
if (cacheEntries.length > 0) {
// Find overlapping cache entries
/*
console.log('Found overlapping cache entries:', overlappingEntries.map(cache => ({
startTime: new Date(cache.startTime).toLocaleString(),
endTime: new Date(cache.endTime).toLocaleString(),
dataPoints: cache.data.length
})));*/
// Check if any single cache entry covers our request
const coveringEntry =overlappingEntries.find(cache => this.doesCacheCover(cache, config))
if(coveringEntry) {
console.log('Found complete coverage in cache:', {
start: new Date(coveringEntry.startTime).toLocaleString(),
end: new Date(coveringEntry.endTime).toLocaleString()
});
return coveringEntry.data.filter(d =>
d.time >= config.startTime && d.time <= config.endTime
);
}
console.log(`Found ${overlappingEntries.length} overlapping entries but no complete coverage`);
}
console.log(`Cache miss for ${symbol} ${config.interval}, fetching from API...`);
// Check if we can build from lower timeframe data
const intervals = Object.keys(this.INTERVALS_MINUTES) as IntervalType[];
const lowerIntervals = intervals.filter(interval =>
this.canDeriveFromCache(interval, config.interval)
);
for (const interval of lowerIntervals) {
const lowerCacheEntries = await storage.getMarketData(symbol, interval);
// Find overlapping entries from lower timeframe
const overlappingEntries = lowerCacheEntries.filter(cache =>
cache.endTime >= config.startTime && cache.startTime <= config.endTime
);
// Check if we have complete coverage from lower timeframe
const hasFullCoverage = overlappingEntries.some(cache =>
this.doesCacheCover(cache, {
...config,
interval: interval
})
);
if (hasFullCoverage) {
// Merge overlapping lower timeframe data
const mergedLowerData = overlappingEntries.reduce((acc, cache) =>
[...acc, ...cache.data], [] as MarketData[]
).sort((a, b) => a.time - b.time);
// We can build the higher timeframe from this data
const aggregatedData = this.aggregateToHigherTimeframe(
mergedLowerData,
interval,
config.interval
);
// Cache the aggregated data
await storage.saveMarketData(symbol, {
data: aggregatedData,
timestamp: Date.now(),
interval: config.interval,
startTime: Math.min(...aggregatedData.map(d => d.time)),
endTime: Math.max(...aggregatedData.map(d => d.time)),
timezone: overlappingEntries[0].timezone, // Assume timezone is consistent
});
return aggregatedData.filter(d =>
d.time >= config.startTime && d.time <= config.endTime
);
}
}
// If we can't build from cache, fetch new data
const {data, timezone} = await this.fetchAppropriateData(symbol, config);
let mergedData = data;
// If we have existing cached data, try to merge
if (cacheEntries.length > 0) {
if (overlappingEntries.length > 0) {
console.log('Attempting to merge with cached data');
mergedData = this.mergeMarketData(
overlappingEntries.reduce((acc, cache) => [...acc, ...cache.data], [] as MarketData[]),
data);
console.log('Merged with overlapping cached data:', {
overlappingCachePoints: overlappingEntries.reduce((sum, cache) => sum + cache.data.length, 0),
newPoints: data.length,
finalPoints: mergedData.length
});
}
}
const newStartTime = Math.min(...mergedData.map(d => d.time));
const newEndTime = Math.max(...mergedData.map(d => d.time));
// Check if this exact time range already exists in cache
const exactMatch = overlappingEntries.some(entry =>
Math.abs(entry.startTime - newStartTime) < 60000 && // Within 1 minute
Math.abs(entry.endTime - newEndTime) < 60000 && // Within 1 minute
entry.interval === config.interval
);
if (!exactMatch) {
await storage.saveMarketData(symbol, {
data: mergedData,
timestamp: Date.now(),
interval: config.interval,
startTime: Math.min(...mergedData.map(d => d.time)),
endTime: Math.max(...mergedData.map(d => d.time)),
timezone
});
} else {
console.log('Skipping cache save - exact time range already exists');
}
return mergedData;
}
catch (error) {
console.error('Error in getMarketData:', error);
throw error; // Re-throw to handle in component
}
}
private static canDeriveFromCache(sourceInterval: IntervalType, targetInterval: IntervalType): boolean {
const sourceMinutes = this.INTERVALS_MINUTES[sourceInterval];
const targetMinutes = this.INTERVALS_MINUTES[targetInterval];
return sourceMinutes < targetMinutes && targetMinutes % sourceMinutes === 0;
}
private static async fetchAppropriateData(
symbol: string,
config: TimeframeConfig
): Promise<{data:MarketData[], timezone: string}> {
if (config.interval === 'daily') {
return await this.fetchHistoricalData(symbol);
} else {
return await this.fetchIntraday(symbol, config.interval);
}
}
private static mergeMarketData(existingData: MarketData[], newData: MarketData[]): MarketData[] {
// Combine both arrays
const combined = [...existingData, ...newData];
// Sort by time
combined.sort((a, b) => a.time - b.time);
// Remove duplicates based on timestamp
return combined.filter((item, index, self) =>
index === 0 || item.time !== self[index - 1].time
);
}
}

View file

@ -1,257 +0,0 @@
// src/services/marketDataStorage.ts
import { TFile, Vault } from 'obsidian';
import { MarketData, CacheEntry, CacheMetadata, IntervalType } from './marketDataService';
export class MarketDataStorage {
private vault: Vault;
private noteFile: TFile;
private basePath: string;
constructor(vault: Vault, noteFile: TFile) {
this.vault = vault;
this.noteFile = noteFile;
// Determine the appropriate base path
this.basePath = this.determineBasePath();
}
private determineBasePath(): string {
// Check if note exists and is a markdown file
if (!this.noteFile || this.noteFile.extension !== 'md') {
throw new Error('Invalid note file');
}
// Get the parent folder path, defaulting to root if none
const parentPath = this.noteFile.parent?.path ?? '';
// If at root, store in vault root
if (!parentPath) {
return '.market-data';
}
// Store in parent folder
return `${parentPath}/.market-data`;
}
getBasePath(): string {
return this.basePath;
}
private async ensureDirectory(path: string) {
try {
if (!(await this.vault.adapter.exists(path))) {
await this.vault.adapter.mkdir(path);
}
} catch (error) {
console.error(`Failed to create directory ${path}:`, error);
throw new Error(`Failed to create market data directory: ${error.message}`);
}
}
async initialize() {
try {
// Ensure base directory exists
await this.ensureDirectory(this.basePath);
// Create metadata file if it doesn't exist
const metadataPath = `${this.basePath}/metadata.json`;
if (!(await this.vault.adapter.exists(metadataPath))) {
await this.vault.adapter.write(
metadataPath,
JSON.stringify({
created: Date.now(),
noteSource: this.noteFile.path
})
);
}
return true;
} catch (error) {
console.error('Failed to initialize market data storage:', error);
throw error;
}
}
// Helper method to get absolute path in vault
getAbsolutePath(relativePath: string): string {
return `${this.basePath}/${relativePath}`;
}
// Method to check if storage is properly initialized
async isInitialized(): Promise<boolean> {
try {
return await this.vault.adapter.exists(this.basePath);
} catch {
return false;
}
}
// Method to get storage location info
async getStorageInfo(): Promise<{
basePath: string;
isInitialized: boolean;
parentFolder: string;
noteFile: string;
}> {
return {
basePath: this.basePath,
isInitialized: await this.isInitialized(),
parentFolder: this.noteFile.parent?.path ?? '(root)',
noteFile: this.noteFile.path
};
}
private getSymbolPath(symbol: string) {
return `${this.basePath}/${symbol}`;
}
private getDataPath(symbol: string, interval: IntervalType) {
return `${this.getSymbolPath(symbol)}/${interval}.json`;
}
async saveMarketData(symbol: string, cacheEntry: CacheEntry): Promise<void> {
await this.initialize();
// Ensure symbol directory exists
const symbolPath = this.getSymbolPath(symbol);
await this.ensureDirectory(symbolPath);
// Save data file
const dataPath = this.getDataPath(symbol, cacheEntry.interval);
await this.vault.adapter.write(
dataPath,
JSON.stringify(cacheEntry, null, 2)
);
// Update metadata
await this.updateMetadata(symbol, cacheEntry);
}
async getMarketData(symbol: string, interval: IntervalType): Promise<CacheEntry[]> {
const dataPath = this.getDataPath(symbol, interval);
if (await this.vault.adapter.exists(dataPath)) {
const content = await this.vault.adapter.read(dataPath);
const parsed = JSON.parse(content);
// Ensure we always return an array
return Array.isArray(parsed) ? parsed : [parsed];
}
return [];
}
private async updateMetadata(symbol: string, cacheEntry: CacheEntry) {
const metadataPath = `${this.basePath}/metadata.json`;
let metadata: CacheMetadata = {};
try{
if (await this.vault.adapter.exists(metadataPath)) {
const content = await this.vault.adapter.read(metadataPath);
metadata = JSON.parse(content);
}
// Update metadata for this symbol
metadata[symbol] = metadata[symbol] || {};
if (!metadata[symbol][cacheEntry.interval]) {
metadata[symbol][cacheEntry.interval] = [];
}
// Add new cache entry info
const cacheList = metadata[symbol][cacheEntry.interval];
if (cacheList) {
cacheList.push({
timestamp: cacheEntry.timestamp,
startTime: cacheEntry.startTime,
endTime: cacheEntry.endTime,
interval: cacheEntry.interval,
data: [], // Don't store actual data in metadata
timezone: cacheEntry.timezone
});
// Keep only last 5 entries
if (cacheList.length > 5) {
cacheList.shift();
}
}
await this.vault.adapter.write(
metadataPath,
JSON.stringify(metadata, null, 2)
);
}catch (error) {
console.error(`Failed to update metadata for ${symbol}:`, error);
throw new Error(`Failed to update metadata: ${error.message}`);
}
}
async getCacheStats(): Promise<{
totalSize: number;
symbolCount: number;
fileCount: number;
symbols: Record<string, {
intervals: IntervalType[];
lastUpdated: number;
}>;
}> {
let totalSize = 0;
let fileCount = 0;
const symbols: Record<string, {
intervals: IntervalType[];
lastUpdated: number;
}> = {};
try{
if (await this.vault.adapter.exists(this.basePath)) {
const files = await this.vault.adapter.list(this.basePath);
for (const file of files.files) {
if (file.endsWith('.json')) {
const stat = await this.vault.adapter.stat(file);
if (!stat) continue; // Skip if stat is null
totalSize += stat.size;
fileCount++;
// Parse symbol and interval info
const pathParts = file.split('/');
if (pathParts.length >= 2) {
const symbol = pathParts[pathParts.length - 2];
const interval = pathParts[pathParts.length - 1].replace('.json', '') as IntervalType;
if (!symbols[symbol]) {
symbols[symbol] = {
intervals: [],
lastUpdated: stat.mtime
};
}
symbols[symbol].intervals.push(interval);
symbols[symbol].lastUpdated = Math.max(symbols[symbol].lastUpdated, stat.mtime);
}
}
}
}
return {
totalSize,
symbolCount: Object.keys(symbols).length,
fileCount,
symbols
};
} catch (error) {
console.error('Failed to get cache stats:', error);
return {
totalSize: 0,
symbolCount: 0,
fileCount: 0,
symbols: {}
};
}
}
async cleanup(maxAge: number = 7 * 24 * 60 * 60 * 1000) { // Default 7 days
const stats = await this.getCacheStats();
const now = Date.now();
for (const [symbol, info] of Object.entries(stats.symbols)) {
if (now - info.lastUpdated > maxAge) {
// Remove old symbol data
const symbolPath = this.getSymbolPath(symbol);
if (await this.vault.adapter.exists(symbolPath)) {
await this.vault.adapter.rmdir(symbolPath, true);
}
}
}
}
}