Initial commit: ReactiveNotes plugin

This commit is contained in:
Elias 2025-02-01 09:31:17 +00:00
commit 0be88d7908
68 changed files with 11316 additions and 0 deletions

10
.editorconfig Normal file
View file

@ -0,0 +1,10 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4

6
.env Normal file
View file

@ -0,0 +1,6 @@
// .env file (Do NOT commit this file)
ALPHA_VANTAGE_PRIMARY_KEY=7NMNWUG3QGNSK9J2
ALPHA_VANTAGE_SECONDARY_KEY=9H7UI9KW24V8UXCW
MARKET_DATA_CACHE_DURATION=86400000
TIMEZONE=US/Eastern

5
.env.example Normal file
View file

@ -0,0 +1,5 @@
// .env.example file
ALPHA_VANTAGE_PRIMARY_KEY=your_primary_key_here
ALPHA_VANTAGE_SECONDARY_KEY=your_secondary_key_here
MARKET_DATA_CACHE_DURATION=86400000
TIMEZONE=US/Eastern

3
.eslintignore Normal file
View file

@ -0,0 +1,3 @@
node_modules/
main.js

23
.eslintrc Normal file
View file

@ -0,0 +1,23 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off"
}
}

BIN
.gitignore vendored Normal file

Binary file not shown.

1
.npmrc Normal file
View file

@ -0,0 +1 @@
tag-version-prefix=""

94
README.md Normal file
View file

@ -0,0 +1,94 @@
# Obsidian Sample Plugin
This is a sample plugin for Obsidian (https://obsidian.md).
This project uses TypeScript to provide type checking and documentation.
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
This sample plugin demonstrates some of the basic functionality the plugin API can do.
- Adds a ribbon icon, which shows a Notice when clicked.
- Adds a command "Open Sample Modal" which opens a Modal.
- Adds a plugin setting tab to the settings page.
- Registers a global click event and output 'click' to the console.
- Registers a global interval which logs 'setInterval' to the console.
## First time developing plugins?
Quick starting guide for new plugin devs:
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
- Install NodeJS, then run `npm i` in the command line under your repo folder.
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
- Reload Obsidian to load the new version of your plugin.
- Enable plugin in settings window.
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
## Releasing new releases
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
- Publish the release.
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
## Adding your plugin to the community plugin list
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines).
- Publish an initial version.
- Make sure you have a `README.md` file in the root of your repo.
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
## How to use
- Clone this repo.
- Make sure your NodeJS is at least v16 (`node --version`).
- `npm i` or `yarn` to install dependencies.
- `npm run dev` to start compilation in watch mode.
## Manually installing the plugin
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
## Improve code quality with eslint (optional)
- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code.
- To use eslint with this project, make sure to install eslint from terminal:
- `npm install -g eslint`
- To use eslint to analyze this project use this command:
- `eslint main.ts`
- eslint will then create a report with suggestions for code improvement by file and line number.
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder:
- `eslint .\src\`
## Funding URL
You can include funding URLs where people who use your plugin can financially support it.
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
```json
{
"fundingUrl": "https://buymeacoffee.com"
}
```
If you have multiple URLs, you can also do:
```json
{
"fundingUrl": {
"Buy Me a Coffee": "https://buymeacoffee.com",
"GitHub Sponsor": "https://github.com/sponsors",
"Patreon": "https://www.patreon.com/"
}
}
```
## API Documentation
See https://github.com/obsidianmd/obsidian-api

66
esbuild.config.mjs Normal file
View file

@ -0,0 +1,66 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.tsx"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2020",
platform: "node", // Add this for Node.js compatibility
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
jsx: "transform", // Add this line
loader: { // Add loader configuration
'.tsx': 'tsx',
'.ts': 'ts',
'.jsx': 'jsx',
'.js': 'js',
'.css': 'css', // Add CSS loader
'.json': 'json' // Add JSON loader for Tailwind config
},
define: { // Add environment definitions
'process.env.NODE_ENV': prod ? '"production"' : '"development"',
global: 'window'
},
inject: [ // Add React shim
'./react-shim.js'
],
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

24
lwc-plugin-custom-primitives/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# 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

@ -0,0 +1,38 @@
# 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

@ -0,0 +1,116 @@
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

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

View file

@ -0,0 +1,17 @@
{
"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

@ -0,0 +1,41 @@
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

@ -0,0 +1,55 @@
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

@ -0,0 +1,57 @@
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

@ -0,0 +1,139 @@
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

@ -0,0 +1,20 @@
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

@ -0,0 +1,23 @@
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

@ -0,0 +1,26 @@
<!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

@ -0,0 +1,33 @@
/**
* 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

@ -0,0 +1,6 @@
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

@ -0,0 +1,23 @@
/**
* 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

@ -0,0 +1,29 @@
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

@ -0,0 +1,48 @@
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

@ -0,0 +1,34 @@
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

@ -0,0 +1,27 @@
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

@ -0,0 +1,46 @@
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

@ -0,0 +1,37 @@
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

@ -0,0 +1,56 @@
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

@ -0,0 +1,35 @@
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

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

View file

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

View file

@ -0,0 +1,21 @@
{
"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"]
}

407
main.tsx Normal file
View file

@ -0,0 +1,407 @@
import { Plugin, MarkdownPostProcessorContext, MarkdownRenderChild, MarkdownRenderer, TFile, App, stringifyYaml } from 'obsidian';
import { createRoot } from 'react-dom/client';
import { transform } from '@babel/standalone';
import * as React from 'react';
import postcss from 'postcss';
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
import { StorageManager } from 'src/core/storage';
import { useStorage } from 'src/hooks/useStorage';
import { createChart } from 'lightweight-charts';
import { Card, CardHeader, CardTitle, CardContent } from 'src/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'src/components/ui/tabs';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import { useMarketData } from 'src/core/useMarketData';
import { createMarketDataHook } from 'src/core/useMarketData';
import { MarketDataService } from 'src/services/marketDataService';
import { Switch } from 'src/components/ui/switch';
import { OrderBlockAnalysisService } from 'src/services/OrderBlockAnalysis';
import { Upload, Activity,AlertCircle, TrendingUp, TrendingDown, } from 'lucide-react';
import { forceSimulation, forceLink, forceManyBody, forceCenter } from 'd3-force';
// Add all React components you want to make available
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
Legend,
ComposedChart,
BarChart,
XAxis,
YAxis,
Bar,
Line,
Area,
ReferenceLine,
LineChart,
} from 'recharts';
import { MarketDataStorage } from 'src/services/marketDataStorage';
class ReactComponentChild extends MarkdownRenderChild {
private root: ReturnType<typeof createRoot>;
private storage: StorageManager;
private static styleSheet: HTMLStyleElement | null = null;
private static componentStyles = new Set<string>();
private noteFile: TFile;
private ctx: MarkdownPostProcessorContext;
private app: App;
constructor(containerEl: HTMLElement, plugin: Plugin, ctx: MarkdownPostProcessorContext) {
super(containerEl);
this.root = createRoot(containerEl);
this.storage = new StorageManager(plugin);
this.ctx = ctx;
this.app = plugin.app; // Get app from plugin
// Get the source file from context
if (ctx.sourcePath) {
this.noteFile = plugin.app.vault.getAbstractFileByPath(ctx.sourcePath) as TFile;
}
// Add these classes to the container
containerEl.classList.add('react-component-container');
if (document.body.hasClass('theme-dark')) {
containerEl.classList.add('theme-dark');
}else {
containerEl.classList.add('theme-light');
}
}
private async getFrontmatterData<T>(key: string, defaultValue: T): Promise<T> {
if (!this.noteFile) return defaultValue;
const cache = this.app.metadataCache.getFileCache(this.noteFile);
const frontmatter = cache?.frontmatter;
return frontmatter?.react_data?.[key] ?? defaultValue;
}
private async updateFrontmatterData<T>(key: string, value: T): Promise<void> {
if (!this.noteFile) return;
const content = await this.app.vault.read(this.noteFile);
const cache = this.app.metadataCache.getFileCache(this.noteFile);
const frontmatter = cache?.frontmatter || {};
const newFrontmatter = {
...frontmatter,
react_data: {
...(frontmatter.react_data || {}),
[key]: value
}
};
const yamlRegex = /^---\n([\s\S]*?)\n---/;
const yamlMatch = content.match(yamlRegex);
let newContent;
if (yamlMatch) {
newContent = content.replace(yamlRegex, `---\n${stringifyYaml(newFrontmatter)}---`);
} else {
newContent = `---\n${stringifyYaml(newFrontmatter)}---\n\n${content}`;
}
await this.app.vault.modify(this.noteFile, newContent);
}
// Add wrapper component for error boundary
private RenderWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<ErrorBoundary
fallback={({ error }) => (
<div className="react-component-error">
<p>Error in component:</p>
<pre className="error-message">
{error.message}
</pre>
</div>
)}
>
{children}
</ErrorBoundary>
);
};
private preprocessCode(code: string): string {
// Remove imports more carefully
code = code.replace(/import\s+.*?['"]\s*;?\s*$/gm, '');
code = code.replace(/import\s*{[^}]*}\s*from\s*['"][^'"]*['"];?\s*$/gm, '');
code = code.replace(/import\s*\([^)]*\);?\s*$/gm, '');
// Handle both JS/TS exports
code = code.replace(/export\s+default\s+/, '');
code = code.replace(/export\s+const\s+/, 'const ');
code = code.replace(/export\s+function\s+/, 'function ');
code = code.replace(/export\s+class\s+/, 'class ');
// Remove type annotations
//code = code.replace(/:\s*[A-Za-z<>[\]]+/g, '');
//code = code.replace(/<[A-Za-z,\s]+>/g, '');
// Create a HOC wrapper for chart components
const chartWrapper = `
const withChartContainer = (WrappedComponent) => {
return function ChartContainer(props) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
const timer = setTimeout(() => setMounted(true), 100);
return () => clearTimeout(timer);
}, []);
return React.createElement(
'div',
{ style: { width: '100%', minHeight: '400px' } },
mounted ? React.createElement(WrappedComponent, props) : null
);
};
};
`;
// Find the component name
const componentMatch = code.match(/(?:const|function|class)\s+(\w+)\s*=\s*(?:(?:\([^)]*\)|)\s*=>|function\s*\(|React\.memo\(|React\.forwardRef\(|class\s+extends\s+React\.Component)/);
const componentName = componentMatch ? componentMatch[1] : 'EmptyComponent';
console.log('Found component:', componentName); // Debug info
if (!componentMatch) {
throw new Error('No React component found');
}
// Combine everything
// added Component assignment
code = `
${chartWrapper}
${code}
const Component = (() => {
// Add error handling for component existence
if (typeof ${componentName} === 'undefined') {
throw new Error(\`Component "${componentName}" was matched but is undefined. Code context: ${code.slice(0, 100)}...\`);
}
const isChartComponent = ${componentName}.toString().includes('ResponsiveContainer') ||
${componentName}.toString().includes('svg');
return isChartComponent ? withChartContainer(${componentName}) : ${componentName};
})();
`;
// Wrap code in async IIFE to allow for await
return `
(async () => {
${code}
return Component;
})()
`;
}
async render(code: string) {
console.time('component-render');
try {
//console.log('Original code:', code);
console.time('preprocess');
// Preprocess code
const processedCode = this.preprocessCode(code);
//console.log('Processed code:', processedCode);
console.timeEnd('preprocess');
console.time('tailwind');
// Process Tailwind
//await this.processTailwind(processedCode);
console.timeEnd('tailwind');
console.time('babel');
const isTypeScript = code.includes(':') || code.includes('interface') || code.includes('<');
const presets: (string | [string, object])[] = isTypeScript ?
['react', ['typescript', { isTSX: true, allExtensions: true }]] :
['react'];
// Transform with Babel
const transformedCode = transform(processedCode, {
presets,
sourceType: 'module',
filename: isTypeScript ? 'dynamic-component.tsx' : 'dynamic-component.jsx',
configFile: false,
babelrc: false,
}).code;
console.timeEnd('babel');
//console.log('Transformed code:', transformedCode);
// Create bound storage hook
// Create bound storage hook with frontmatter support
const boundUseStorage = <T,>(key: string, defaultValue: T) => {
const [value, setValue] = React.useState<T>(defaultValue);
React.useEffect(() => {
this.getFrontmatterData(key, defaultValue).then(setValue);
}, [key]);
const updateValue = React.useCallback(
async (newValue: T | ((prev: T) => T)) => {
const actualNewValue = newValue instanceof Function ?
newValue(value) : newValue;
setValue(actualNewValue);
await this.updateFrontmatterData(key, actualNewValue);
},
[key, value]
);
return [value, updateValue] as const;
};
// Create scoped market data hook
const useMarketData = createMarketDataHook(this.storage, this.noteFile);
// Create scope with all required dependencies
const scope = {
React,
useState: React.useState,
useEffect: React.useEffect,
useRef: React.useRef,
useMemo: React.useMemo,
useStorage: boundUseStorage,
useMarketData,
useCallback:React.useCallback,
MarketDataStorage,
OrderBlockAnalysisService,
MarketDataService,
// Chart library
createChart,
// UI Components
Card,
CardHeader,
CardTitle,
CardContent,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Switch,
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
Legend,
ComposedChart, // Add these chart components
BarChart,
XAxis,
YAxis,
Bar,
Upload,
Area,
Line,
ReferenceLine,
LineChart,
Activity,
AlertCircle,
TrendingUp, TrendingDown,
forceSimulation, forceLink, forceManyBody, forceCenter,
getTheme: () => document.body.hasClass('theme-dark') ? 'dark' : 'light',
// Add note context
noteContext: {
path: this.noteFile?.path,
basename: this.noteFile?.basename,
frontmatter: this.app.metadataCache.getFileCache(this.noteFile)?.frontmatter
},
// Add useful chart utilities
getChartTheme: () => document.body.hasClass('theme-dark') ? 'dark' : 'light',
getChartDefaults: () => ({
margin: { top: 10, right: 30, left: 0, bottom: 0 },
style: {
backgroundColor: document.body.hasClass('theme-dark') ? '#1a1b1e' : '#ffffff',
color: document.body.hasClass('theme-dark') ? '#ffffff' : '#000000'
}
}),
};
// Execute the code and get the component
const Component = await new Function(
...Object.keys(scope),
`return ${transformedCode}`
)(...Object.values(scope));
// Wrap the rendered component in ErrorBoundary
this.root.render(
<this.RenderWrapper>
<Component />
</this.RenderWrapper>
);
} catch (error) {
console.error('Rendering error:', error);
this.renderError(error instanceof Error ? error : new Error('Unknown error'));
}
console.timeEnd('component-render');
}
onunload() {
this.root.unmount();
}
renderError(error: Error) {
this.root.render(
React.createElement('div', {
style: {
color: 'red',
padding: '1rem',
border: '1px solid red',
borderRadius: '4px',
margin: '1rem 0'
}
}, `Error: ${error.message}`)
);
}
getStorage() {
return this.storage;
}
}
export default class ReactTestPlugin extends Plugin {
async onload() {
// Listen for theme changes
this.registerEvent(
this.app.workspace.on('css-change', this.updateTheme)
);
// Register markdown processor
this.registerMarkdownCodeBlockProcessor(
'react',
(source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
const child = new ReactComponentChild(el, this, ctx);
ctx.addChild(child);
child.render(source);
}
);
// Register a global Markdown postprocessor for HTML
this.registerMarkdownPostProcessor((element, context) => {
this.parseMarkdownInHtml(element);
});
// Initial theme setup
this.updateTheme();
}
private updateTheme = () => {
// Update theme class on all react component containers
document.querySelectorAll('.react-component-container').forEach(el => {
if (document.body.hasClass('theme-dark')) {
el.classList.add('theme-dark');
el.classList.remove('theme-light');
} else {
el.classList.add('theme-light');
el.classList.remove('theme-dark');
}
});
};
private async parseMarkdownInHtml(container: HTMLElement) {
// Query all divs or specific HTML blocks you want to process
const htmlBlocks = Array.from(container.querySelectorAll('div')) as HTMLDivElement[];
// Iterate over each div block
for (const block of htmlBlocks) {
// Check if it already contains rendered Markdown (to avoid double processing)
if (block.querySelector('.markdown-rendered')) continue;
// Render Markdown for the inner content of each block
await MarkdownRenderer.renderMarkdown(
block.innerHTML, // Raw HTML content
block, // Target container for rendered Markdown
'', // Path (optional, current file path if needed)
this // Plugin context
);
// After rendering, mark the block to avoid double processing
block.classList.add('markdown-rendered');
}
}
}

11
manifest.json Normal file
View file

@ -0,0 +1,11 @@
{
"id": "reactive-notes",
"name": "Reactive Notes",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Enhances the Obsidian API with react.",
"author": "Obsidian",
"authorUrl": "https://obsidian.md",
"fundingUrl": "https://obsidian.md/pricing",
"isDesktopOnly": true
}

6204
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

58
package.json Normal file
View file

@ -0,0 +1,58 @@
{
"name": "obsidian-sample-plugin",
"version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@babel/core": "^7.26.0",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/plugin-transform-modules-commonjs": "^7.25.9",
"@babel/preset-react": "^7.25.9",
"@babel/preset-typescript": "^7.26.0",
"@babel/standalone": "^7.26.2",
"@types/d3-force": "^3.0.10",
"@types/node": "^16.11.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "^4.8.0"
},
"dependencies": {
"@codemirror/language": "^6.10.6",
"@emotion/css": "^11.13.5",
"@lezer/common": "^1.2.3",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.1.1",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"lightweight-charts": "^4.2.1",
"lucide-react": "^0.462.0",
"postcss": "^8.4.49",
"react": "^18.3.1",
"react-apexcharts": "^1.6.0",
"react-dom": "^18.3.1",
"react-force-graph": "^1.46.0",
"recharts": "^2.13.3",
"shadcn": "^1.0.0",
"tailwind-merge": "^2.5.5",
"tailwindcss": "^3.4.16",
"yaml": "^2.6.1"
}
}

4
react-shim.js vendored Normal file
View file

@ -0,0 +1,4 @@
// react-shim.js
import React from 'react';
import ReactDOM from 'react-dom';
export { React, ReactDOM };

View file

@ -0,0 +1,133 @@
// 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

@ -0,0 +1,135 @@
// src/components/ErrorBoundary.tsx
import React from 'react';
import { RefreshCw } from 'lucide-react'; // Assuming you're using lucide-react for icons
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ComponentType<{ error: Error }>;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: React.ErrorInfo | null;
}
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null
};
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return {
hasError: true,
error
};
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
this.setState({
error,
errorInfo
});
// Log error to console for debugging
console.error('Error caught by boundary:', error, errorInfo);
}
handleRetry = (): void => {
this.setState({
hasError: false,
error: null,
errorInfo: null
});
};
render() {
if (this.state.hasError) {
// Use custom fallback if provided
if (this.props.fallback) {
const FallbackComponent = this.props.fallback;
return <FallbackComponent error={this.state.error!} />;
}
// Default error UI
return (
<div className="react-component-error p-4 rounded-md border border-red-500 bg-red-50 dark:bg-red-900/10">
<div className="flex items-center justify-between mb-2">
<h4 className="text-red-700 dark:text-red-400 font-medium">
Component Error
</h4>
<button
onClick={this.handleRetry}
className="flex items-center gap-1 px-2 py-1 text-sm rounded
bg-red-100 dark:bg-red-800/30
hover:bg-red-200 dark:hover:bg-red-800/50
text-red-700 dark:text-red-400
transition-colors"
>
<RefreshCw className="w-4 h-4" />
Retry
</button>
</div>
<div className="text-sm text-red-600 dark:text-red-300">
{this.state.error?.message || 'An unexpected error occurred'}
</div>
{process.env.NODE_ENV === 'development' && this.state.errorInfo && (
<details className="mt-2">
<summary className="text-sm text-red-500 dark:text-red-400 cursor-pointer">
Stack trace
</summary>
<pre className="mt-2 p-2 text-xs overflow-auto bg-red-100/50 dark:bg-red-950/50 rounded">
{this.state.errorInfo.componentStack}
</pre>
</details>
)}
</div>
);
}
return (
<div className="relative">
{this.props.children}
</div>
);
}
}
// Higher-order component for easier usage
export function withErrorBoundary<P extends object>(
Component: React.ComponentType<P>,
fallback?: React.ComponentType<{ error: Error }>
): React.FC<P> {
return function WrappedComponent(props: P) {
return (
<ErrorBoundary fallback={fallback}>
<Component {...props} />
</ErrorBoundary>
);
};
}
// Example usage of custom fallback:
/*
const CustomFallback: React.FC<{ error: Error }> = ({ error }) => (
<div className="custom-error">
<h4>Something went wrong</h4>
<p>{error.message}</p>
</div>
);
// Use with custom fallback
<ErrorBoundary fallback={CustomFallback}>
<YourComponent />
</ErrorBoundary>
// Or use HOC
const SafeComponent = withErrorBoundary(YourComponent, CustomFallback);
*/

View file

@ -0,0 +1,36 @@
// 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

@ -0,0 +1,92 @@
// 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)
};
}
}

57
src/components/styled.tsx Normal file
View file

@ -0,0 +1,57 @@
// 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

View file

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "src/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View file

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "src/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View file

@ -0,0 +1,52 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "src/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

84
src/core/dynamic.tsx Normal file
View file

@ -0,0 +1,84 @@
// 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
);
}

47
src/core/registry.tsx Normal file
View file

@ -0,0 +1,47 @@
// src/core/registry.tsx
import React, { FC, ComponentProps } from 'react';
import { dynamic, dynamicNamed } from './dynamic';
interface Registry {
PieChart: FC<ComponentProps<any>>;
Chart: FC<ComponentProps<any>>;
ForceGraph2D: FC<ComponentProps<any>>;
[key: string]: FC<ComponentProps<any>>; // Add index signature
Pie: FC<ComponentProps<any>>;
Cell: FC<ComponentProps<any>>;
ResponsiveContainer: FC<ComponentProps<any>>;
Tooltip: FC<ComponentProps<any>>;
Legend: FC<ComponentProps<any>>;
}
export const ComponentRegistry: Registry = {
// For named exports from recharts, use dynamicNamed
PieChart: dynamicNamed(() =>
import('recharts').then(mod => mod.PieChart)
),
Pie: dynamicNamed(() =>
import('recharts').then(mod => mod.Pie)
),
Cell: dynamicNamed(() =>
import('recharts').then(mod => mod.Cell)
),
ResponsiveContainer: dynamicNamed(() =>
import('recharts').then(mod => mod.ResponsiveContainer)
),
Tooltip: dynamicNamed(() =>
import('recharts').then(mod => mod.Tooltip)
),
Legend: dynamicNamed(() =>
import('recharts').then(mod => mod.Legend)
),
// For components with default exports, use dynamic
Chart: dynamic(() => import('react-apexcharts')),
ForceGraph2D: dynamicNamed(() =>
import('react-force-graph').then(mod => mod.ForceGraph2D)
)
};
export const getRegisteredComponent = (name: string): FC<ComponentProps<any>> | null => {
return ComponentRegistry[name] || null;
};

212
src/core/scope.ts Normal file
View file

@ -0,0 +1,212 @@
// 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>
);
}
*/

109
src/core/storage.ts Normal file
View file

@ -0,0 +1,109 @@
// src/core/storage.tsx
import { MarketDataService, MarketData } from '../services/marketDataService';
import { Plugin, TFile,parseYaml,stringifyYaml } from 'obsidian';
export class StorageManager {
private plugin: Plugin;
private cache: Map<string, any>;
constructor(plugin: Plugin) {
this.plugin = plugin;
this.cache = new Map();
}
private getCacheKey(noteFile: TFile, key: string): string {
return `${noteFile.path}:${key}`;
}
private async getFrontmatter(noteFile: TFile) {
const metadata = this.plugin.app.metadataCache.getFileCache(noteFile)?.frontmatter;
return metadata?.react_data || {};
}
async getMarketData(symbol: string, interval: string, range: string, noteFile: TFile): Promise<MarketData[]> {
const cacheKey = `market-data-${symbol}-${interval}-${range}`;
// Get from frontmatter
const frontmatter = await this.getFrontmatter(noteFile);
const cached = frontmatter[cacheKey];
// Check if we have cached data and if it's still fresh (24 hours)
if (cached && Date.now() - cached.timestamp < 24 * 60 * 60 * 1000) {
return cached.data;
}
// Fetch new data
const data = (await MarketDataService.fetchHistoricalData(symbol)).data;
// Cache the data using existing set method
await this.set(cacheKey, {
data,
timestamp: Date.now()
}, noteFile);
return data;
}
async get<T>(key: string, defaultValue: T, noteFile: TFile): Promise<T> {
const cacheKey = this.getCacheKey(noteFile, key);
// Check cache first
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
}
// Get from frontmatter
const frontmatter = await this.getFrontmatter(noteFile);
const value = frontmatter[key] ?? defaultValue;
// Update cache
this.cache.set(cacheKey, value);
return value;
}
async set<T>(key: string, value: T, noteFile: TFile): Promise<void> {
const cacheKey = this.getCacheKey(noteFile, key);
this.cache.set(cacheKey, value);
const content = await this.plugin.app.vault.read(noteFile);
const frontmatter = await this.getFrontmatter(noteFile);
// Prepare new frontmatter
const newFrontmatter = {
...frontmatter,
[key]: value
};
// Update the file with new frontmatter
const updatedContent = this.updateFrontmatter(content, newFrontmatter);
await this.plugin.app.vault.modify(noteFile, updatedContent);
}
private updateFrontmatter(content: string, newData: any): string {
const frontmatterRegex = /^---\n([\s\S]*?)\n---/;
const frontmatterMatch = content.match(frontmatterRegex);
if (frontmatterMatch) {
// Update existing frontmatter
let frontmatter;
try {
frontmatter = parseYaml(frontmatterMatch[1]);
} catch (e) {
frontmatter = {};
}
frontmatter.react_data = {
...frontmatter.react_data,
...newData
};
const newFrontmatter = stringifyYaml(frontmatter);
return content.replace(frontmatterRegex, `---\n${newFrontmatter}---`);
} else {
// Create new frontmatter
const newFrontmatter = stringifyYaml({ react_data: newData });
return `---\n${newFrontmatter}---\n\n${content}`;
}
}
clearCache() {
this.cache.clear();
}
}

80
src/core/styles.ts Normal file
View file

@ -0,0 +1,80 @@
// src/core/styles.ts
export const componentStyles = {
// Base component styles
reactComponent: `
padding: 1rem;
border-radius: 0.5rem;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
`,
// Button styles
reactComponentButton: `
padding: 0.5rem 1rem;
color: var(--text-on-accent);
background-color: var(--interactive-accent);
border-radius: 0.25rem;
&:hover {
background-color: var(--interactive-accent-hover);
}
`,
// Chart-specific styles
chartContainer: (theme: 'dark' | 'light') => `
background: ${theme === 'dark' ? '#1e293b' : '#ffffff'};
color: ${theme === 'dark' ? '#94a3b8' : '#475569'};
border: 1px solid var(--background-modifier-border);
border-radius: 0.5rem;
min-height: 400px;
`,
// List styles
listContent: `
margin: 0;
padding-left: 1.5rem;
list-style-type: none;
li {
margin-bottom: 0.5rem;
color: var(--text-normal);
}
`,
};
// Common utility classes that don't need runtime processing
export const utilityClasses = {
layout: {
'w-full': 'width: 100%;',
'grid': 'display: grid;',
'grid-cols-2': 'grid-template-columns: repeat(2, minmax(0, 1fr));',
'gap-4': 'gap: 1rem;',
},
spacing: {
'mt-4': 'margin-top: 1rem;',
'mb-2': 'margin-bottom: 0.5rem;',
'p-4': 'padding: 1rem;',
},
colors: {
'text-green-500': 'color: var(--color-green);',
'text-red-500': 'color: var(--color-red);',
'text-blue-500': 'color: var(--interactive-accent);',
}
};
// Theme-aware chart configuration
export const getChartConfig = (theme: 'dark' | 'light') => ({
layout: {
background: {
color: theme === 'dark' ? '#1e293b' : '#ffffff'
},
textColor: theme === 'dark' ? '#94a3b8' : '#475569',
},
grid: {
vertLines: {
color: theme === 'dark' ? '#334155' : '#e2e8f0'
},
horzLines: {
color: theme === 'dark' ? '#334155' : '#e2e8f0'
},
}
});

34
src/core/tailwind.ts Normal file
View file

@ -0,0 +1,34 @@
// 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;
}
}

205
src/core/transformer.ts Normal file
View file

@ -0,0 +1,205 @@
// src/core/transformer.ts
import { transform as babelTransform } from '@babel/standalone';
interface TransformOptions {
scope?: Record<string, any>;
filename?: string;
isTypeScript?: boolean;
isInline?: boolean;
}
interface TransformResult {
code: string;
component: React.ComponentType;
error?: Error;
}
export class ComponentTransformer {
private static defaultScope = {
React: window.React,
useState: window.React.useState,
useEffect: window.React.useEffect,
useRef: window.React.useRef,
useMemo: window.React.useMemo,
useCallback: window.React.useCallback,
};
static async transform(code: string, options: TransformOptions = {}): Promise<TransformResult> {
try {
// Prepare the code
const processedCode = this.preprocessCode(code, options);
// Transform the code using Babel
const transformed = await this.babelTransform(processedCode, options);
// Create the component
const component = await this.createComponent(transformed, options);
return {
code: transformed,
component
};
} catch (error) {
console.error('Transformation error:', error);
throw this.enhanceError(error);
}
}
private static preprocessCode(code: string, options: TransformOptions): string {
// Remove imports and exports first
code = code.replace(/import\s+.*?['"];?\s*$/gm, '');
code = code.replace(/import\s*{[^}]*}\s*from\s*['"][^'"]*['"];?\s*$/gm, '');
code = code.replace(/import\s*\([^)]*\);?\s*$/gm, '');
code = code.replace(/export\s+default\s+/, '');
code = code.replace(/export\s+const\s+/, 'const ');
code = code.replace(/export\s+function\s+/, 'function ');
code = code.replace(/export\s+class\s+/, 'class ');
// Handle any named component/function conversion to Component
const namedComponentMatch = code.match(/const\s+(\w+)\s*=\s*\(\)\s*=>/);
if (namedComponentMatch) {
code = code.replace(
`const ${namedComponentMatch[1]} = () =>`,
'const Component = () =>'
);
} else if (!code.includes('const Component =') && !code.includes('function Component')) {
code = `const Component = () => ${code}`;
}
return `
try {
${code}
} catch (error) {
console.error('Error in component code:', error);
throw error;
}
`;
}
private static async babelTransform(code: string, options: TransformOptions): Promise<string> {
const transformed = babelTransform(code, {
presets: [
'react',
...(options.isTypeScript ? ['typescript'] : [])
] as string[],
filename: options.filename || 'component.jsx',
sourceType: 'module',
configFile: false,
babelrc: false,
plugins: [
// Add any custom plugins here
this.createScopePlugin(options.scope)
]
});
return transformed.code;
}
private static async createComponent(code: string, options: TransformOptions): Promise<React.ComponentType> {
// Create the scope with defaults and custom additions
const scope = {
...this.defaultScope,
...options.scope
};
// Create the component function
const componentFn = new Function(
...Object.keys(scope),
`
${code}
return typeof Component !== 'undefined'
? Component
: () => null;
`
);
try {
// Execute with scope
return componentFn(...Object.values(scope));
} catch (error) {
throw new Error(`Error creating component: ${error.message}`);
}
}
private static createScopePlugin(customScope: Record<string, any> = {}) {
// Custom babel plugin to handle scope
return {
visitor: {
Identifier(path: any) {
const name = path.node.name;
if (
path.scope.hasBinding(name) ||
this.defaultScope.hasOwnProperty(name) ||
customScope.hasOwnProperty(name)
) {
return;
}
// Replace unknown identifiers with scoped access
path.replaceWith(
path.scope.buildUndefinedNode()
);
}
}
};
}
private static getImports(): string {
return `
// Add any necessary imports here
const { useState, useEffect, useRef, useMemo, useCallback } = React;
`;
}
private static getExports(): string {
return `
if (typeof Component === 'undefined') {
throw new Error('No component defined');
}
`;
}
private static stripTypeAnnotations(code: string): string {
// Basic TypeScript stripping - in a real implementation,
// you might want to use a proper TS transformer
return code
.replace(/:\s*[A-Za-z<>[\]]+/g, '')
.replace(/<[A-Za-z,\s]+>/g, '');
}
private static enhanceError(error: Error): Error {
// Enhance error messages for better debugging
if (error.message.includes('Component')) {
return new Error(`Component Error: ${error.message}`);
}
if (error.message.includes('React')) {
return new Error(`React Error: ${error.message}`);
}
return error;
}
// Utility method for checking if code is TypeScript
static isTypeScript(code: string): boolean {
return /:\s*[A-Za-z]+/.test(code) || /<[A-Za-z,\s]+>/.test(code);
}
}
// Example usage:
/*
const code = `
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
`;
const { component: Counter } = await ComponentTransformer.transform(code, {
scope: {
// Additional scope variables
customVar: 'value'
},
isInline: true
});
*/

View file

@ -0,0 +1,98 @@
// 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 };
};
}

27
src/hooks/useStorage.ts Normal file
View file

@ -0,0 +1,27 @@
// src/hooks/useStorage.tsx
import { useState, useEffect } from 'react';
import { TFile } from 'obsidian';
import { StorageManager } from '../core/storage';
export function useStorage<T>(
key: string,
defaultValue: T,
storage: StorageManager,
noteFile: TFile
): [T, (value: T | ((prev: T) => T)) => Promise<void>] {
const [value, setValue] = useState<T>(defaultValue);
// Load initial value
useEffect(() => {
storage.get(key, defaultValue, noteFile).then(setValue);
}, [key, defaultValue, noteFile]);
// Update function
const updateValue = async (newValue: T | ((prev: T) => T)) => {
const actualNewValue = newValue instanceof Function ? newValue(value) : newValue;
setValue(actualNewValue);
await storage.set(key, actualNewValue, noteFile);
};
return [value, updateValue];
}

7
src/lib/utils.ts Normal file
View file

@ -0,0 +1,7 @@
//src/lib/util.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -0,0 +1,793 @@
// 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

@ -0,0 +1,459 @@
// 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

@ -0,0 +1,257 @@
// 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);
}
}
}
}
}

31
src/types/babel-standalone.d.ts vendored Normal file
View file

@ -0,0 +1,31 @@
// src/types/babel-standalone.d.ts
declare module '@babel/standalone' {
export interface TransformOptions {
filename?: string;
presets?: (string | [string, object])[]; //Also allow tuple
plugins?: any[];
configFile?: boolean | string;
babelrc?: boolean;
sourceType?: 'script' | 'module' | 'unambiguous';
sourceMaps?: boolean;
sourceFileName?: string;
code?: boolean;
ast?: boolean;
minified?: boolean;
}
export interface TransformResult {
code: string;
map?: any;
ast?: any;
metadata?: any;
}
export function transform(
code: string,
options?: TransformOptions
): TransformResult;
export const availablePresets: { [key: string]: any };
export const availablePlugins: { [key: string]: any };
}

7
src/types/global.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
declare global {
interface Window {
React: typeof import('react');
}
}
export {};

335
styles.css Normal file
View file

@ -0,0 +1,335 @@
/* styles.css */
/* Spacing utilities */
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-4 { margin-top: 1rem; }
.mb-2 { margin-bottom: 0.5rem; }
.p-2 { padding: 0.5rem; }
.p-4 { padding: 1rem; }
/* Layout utilities */
.flex { display: flex; }
.grid { display: grid; }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.gap-2 { gap: 0.5rem; }
.gap-4 { gap: 1rem; }
.w-full { width: 100%; }
.space-y-2 > * + * { margin-top: 0.5rem; }
/* Colors - using Obsidian CSS variables */
.text-green-500 { color: var(--color-green); }
.text-red-500 { color: var(--color-red); }
.text-blue-500 { color: var(--interactive-accent); }
/* Font utilities */
.font-medium { font-weight: 500; }
.text-sm { font-size: 0.875rem; }
.text-lg { font-size: 1.125rem; }
/* Border utilities */
.rounded { border-radius: 4px; }
.border { border: 1px solid var(--background-modifier-border); }
.border-slate-700 { border-color: var(--background-modifier-border-focus); }
/* Component styles */
.react-plugin-card {
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
background: var(--background-primary);
margin-bottom: 1rem; }
.react-plugin-card-header {
padding: 1rem;
border-bottom: 1px solid var(--background-modifier-border); }
.react-plugin-card-title {
margin: 0;
font-size: 1.1em;
font-weight: 600;
color: var(--text-normal); }
.react-plugin-card-content { padding: 1rem; }
.react-plugin-tabs {
display: flex;
flex-direction: column;
gap: 1rem; }
.react-plugin-tabs-list {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: 0.5rem; }
.react-plugin-tab-trigger {
padding: 0.5rem 1rem;
border: none;
background: none;
cursor: pointer;
color: var(--text-muted);
border-radius: 4px; }
.react-plugin-tab-trigger[data-state="active"] {
background: var(--background-modifier-hover);
color: var(--text-normal); }
/* Chart container styles */
.chart-container {
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 0;
margin: 0;
overflow: hidden; /* Add this to prevent any overflow */
min-height: 400px;
background: var(--background-secondary); }
.tv-lightweight-charts {
height: 500px !important; /* Force height if needed
overflow: visible; /* Show x-axis labels if they overflow */
}
/* List styles */
.list-content {
margin: 0;
padding-left: 1.5rem;
list-style-type: none; }
.list-content li {
margin-bottom: 0.5rem;
color: var(--text-normal); }
.heading {
margin: 0 0 0.5rem 0;
font-weight: 600; }
/* Prevent conflicts with Obsidian's styles */
.react-plugin-card button { box-shadow: none; }
.react-plugin-card button:hover { box-shadow: none; }
.react-component-error {
margin: 0.5rem 0;
font-size: 0.875rem;
}
.react-component-error pre {
white-space: pre-wrap;
word-break: break-word;
}
/* Dark mode adjustments are handled by Tailwind classes */
/*
This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
If your plugin does not need CSS, delete this file.
*/
/* Chart container styles */
.recharts-wrapper {
min-height: 400px !important;
min-width: 300px !important;
}
/* Ensure proper sizing for ResponsiveContainer */
.recharts-responsive-container {
min-height: 400px !important;
width: 100% !important;
}
/* Dark mode adjustments for charts */
.theme-dark .recharts-cartesian-grid line {
stroke: rgba(255, 255, 255, 0.1);
}
.theme-dark .recharts-text {
fill: rgba(255, 255, 255, 0.8);
}
.theme-dark .recharts-tooltip-wrapper {
background-color: #1a1b1e;
border: 1px solid rgba(255, 255, 255, 0.1);
}
/*Tail Wind stuff */
/* styles.css */
/* Layout */
.w-full { width: 100%; }
.w-3\/4 { width: 75%; }
.w-1\/2 { width: 50%; }
.h-64 { height: 16rem; }
.h-32 { height: 8rem; }
.h-16 { height: 4rem; }
.relative { position: relative; }
.absolute { position: absolute; }
/* Spacing */
.p-4 { padding: 1rem; }
.mt-16 { margin-top: 4rem; }
.mt-8 { margin-top: 2rem; }
.mt-4 { margin-top: 1rem; }
.mb-4 { margin-bottom: 1rem; }
.ml-8 { margin-left: 2rem; }
.top-2 { top: 0.5rem; }
.left-2 { left: 0.5rem; }
/* Block colors - with dark mode support */
.bg-red-200 {
background-color: rgba(254, 202, 202, 0.8);
}
.bg-orange-200 {
background-color: rgba(254, 215, 170, 0.8);
}
.bg-yellow-200 {
background-color: rgba(254, 240, 138, 0.8);
}
.bg-slate-100 {
background-color: rgba(241, 245, 249, 0.8);
}
.theme-dark .bg-red-200 {
background-color: rgba(254, 202, 202, 0.2);
}
.theme-dark .bg-orange-200 {
background-color: rgba(254, 215, 170, 0.2);
}
.theme-dark .bg-yellow-200 {
background-color: rgba(254, 240, 138, 0.2);
}
.theme-dark .bg-slate-100 {
background-color: rgba(241, 245, 249, 0.1);
}
/* Border colors */
.border-red-500 { border-color: rgb(239, 68, 68); }
.border-orange-500 { border-color: rgb(249, 115, 22); }
.border-yellow-500 { border-color: rgb(234, 179, 8); }
/* Border styles */
.border-2 { border-width: 2px; }
.rounded-lg { border-radius: 0.5rem; }
/* Grid */
.grid { display: grid; }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.gap-4 { gap: 1rem; }
/* Typography */
.text-sm { font-size: 0.875rem; }
.font-medium { font-weight: 500; }
/* Flex */
.flex { display: flex; }
.items-center { align-items: center; }
/* Spacing utilities */
.space-y-4 > * + * { margin-top: 1rem; }
.space-y-2 > * + * { margin-top: 0.5rem; }
/* Container styles - ensures proper rendering in Obsidian */
.react-component-container {
width: 100%;
margin: 1rem 0;
font-family: var(--font-interface);
color: var(--text-normal);
}
.theme-dark .react-component-container {
color-scheme: dark;
}
/* Card styles */
.card {
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 0.5rem;
width: 100%;
}
.card-header {
padding: 1.5rem;
}
.card-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.card-content {
padding: 1.5rem;
}
/* Base text positioning */
.nested-block-label {
position: absolute;
top: 8px;
left: 8px;
font-size: 0.875rem;
font-weight: 500;
z-index: 10; /* Ensure text stays above blocks */
background-color: inherit; /* Match parent block background */
padding: 2px 6px;
border-radius: 4px;
}
/* Ensure blocks stack properly */
.nested-block {
position: relative;
border: 2px solid;
border-radius: 8px;
margin-bottom: 1rem;
z-index: 1; /* Base z-index */
}
/* Specific z-indices for each block type */
.nested-block.primary {
z-index: 1;
}
.nested-block.secondary {
z-index: 2;
}
.nested-block.tertiary {
z-index: 3;
}
/* Adjust backgrounds to be slightly transparent */
.bg-red-200 {
background-color: rgba(254, 202, 202, 0.95);
}
.bg-orange-200 {
background-color: rgba(254, 215, 170, 0.95);
}
.bg-yellow-200 {
background-color: rgba(254, 240, 138, 0.95);
}
/* Dark mode adjustments */
.theme-dark .bg-red-200 {
background-color: rgba(254, 202, 202, 0.15);
}
.theme-dark .bg-orange-200 {
background-color: rgba(254, 215, 170, 0.15);
}
.theme-dark .bg-yellow-200 {
background-color: rgba(254, 240, 138, 0.15);
}
/* Ensure proper spacing */
.nested-block + .nested-block {
margin-top: 2rem;
}

37
tsconfig.json Normal file
View file

@ -0,0 +1,37 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2020",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"allowSyntheticDefaultImports": true,
"jsx": "react",
"jsxFactory": "React.createElement", // Add this
"jsxFragmentFactory": "React.Fragment", // Add this
"lib": [
"DOM",
"ES5",
"ES6",
"ES7",
"ES2020"
],
"typeRoots": [
"./node_modules/@types",
"./src/types"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
"src/**/*.ts",
"src/**/*.tsx",
"src/types/**/*.d.ts"
]
}

14
version-bump.mjs Normal file
View file

@ -0,0 +1,14 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "0.15.0"
}