@blockrun/franklin 3.2.4 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +216 -233
- package/dist/agent/commands.js +54 -13
- package/dist/agent/context.js +31 -1
- package/dist/agent/loop.js +48 -19
- package/dist/agent/permissions.js +3 -3
- package/dist/commands/migrate.d.ts +13 -0
- package/dist/commands/migrate.js +389 -0
- package/dist/commands/panel.d.ts +6 -0
- package/dist/commands/panel.js +29 -0
- package/dist/commands/start.js +41 -2
- package/dist/events/bridge.d.ts +1 -0
- package/dist/events/bridge.js +24 -0
- package/dist/events/bus.d.ts +17 -0
- package/dist/events/bus.js +55 -0
- package/dist/events/types.d.ts +49 -0
- package/dist/events/types.js +8 -0
- package/dist/index.js +15 -0
- package/dist/learnings/extractor.d.ts +16 -0
- package/dist/learnings/extractor.js +234 -0
- package/dist/learnings/index.d.ts +3 -0
- package/dist/learnings/index.js +2 -0
- package/dist/learnings/store.d.ts +15 -0
- package/dist/learnings/store.js +130 -0
- package/dist/learnings/types.d.ts +24 -0
- package/dist/learnings/types.js +7 -0
- package/dist/mcp/client.js +9 -2
- package/dist/narrative/state.d.ts +30 -0
- package/dist/narrative/state.js +69 -0
- package/dist/panel/html.d.ts +5 -0
- package/dist/panel/html.js +341 -0
- package/dist/panel/server.d.ts +7 -0
- package/dist/panel/server.js +152 -0
- package/dist/session/storage.js +4 -2
- package/dist/social/browser-pool.d.ts +29 -0
- package/dist/social/browser-pool.js +57 -0
- package/dist/social/preflight.d.ts +14 -0
- package/dist/social/preflight.js +26 -0
- package/dist/social/x.d.ts +8 -0
- package/dist/social/x.js +9 -1
- package/dist/stats/tracker.d.ts +1 -0
- package/dist/stats/tracker.js +59 -13
- package/dist/tools/bash.js +6 -1
- package/dist/tools/index.js +3 -0
- package/dist/tools/posttox.d.ts +7 -0
- package/dist/tools/posttox.js +137 -0
- package/dist/tools/searchx.d.ts +7 -0
- package/dist/tools/searchx.js +111 -0
- package/dist/tools/trading.d.ts +3 -0
- package/dist/tools/trading.js +168 -0
- package/dist/tools/webfetch.js +19 -9
- package/dist/tools/write.js +2 -0
- package/dist/trading/config.d.ts +23 -0
- package/dist/trading/config.js +45 -0
- package/dist/trading/data.d.ts +30 -0
- package/dist/trading/data.js +112 -0
- package/dist/trading/metrics.d.ts +29 -0
- package/dist/trading/metrics.js +105 -0
- package/dist/ui/app.js +73 -44
- package/dist/ui/markdown.d.ts +9 -0
- package/dist/ui/markdown.js +86 -0
- package/package.json +1 -1
package/dist/tools/webfetch.js
CHANGED
|
@@ -9,27 +9,30 @@ const MAX_BODY_BYTES = 256 * 1024; // 256KB
|
|
|
9
9
|
const CACHE_TTL_MS = 15 * 60 * 1000;
|
|
10
10
|
const MAX_CACHE_ENTRIES = 50;
|
|
11
11
|
const fetchCache = new Map();
|
|
12
|
-
function
|
|
13
|
-
|
|
12
|
+
function cacheKey(url, maxLength) {
|
|
13
|
+
return `${url}::${maxLength}`;
|
|
14
|
+
}
|
|
15
|
+
function getCached(key) {
|
|
16
|
+
const entry = fetchCache.get(key);
|
|
14
17
|
if (!entry)
|
|
15
18
|
return null;
|
|
16
19
|
if (Date.now() > entry.expiresAt) {
|
|
17
|
-
fetchCache.delete(
|
|
20
|
+
fetchCache.delete(key);
|
|
18
21
|
return null;
|
|
19
22
|
}
|
|
20
23
|
return entry.output;
|
|
21
24
|
}
|
|
22
|
-
function setCached(
|
|
25
|
+
function setCached(key, output) {
|
|
23
26
|
// Evict oldest entry if at capacity
|
|
24
27
|
if (fetchCache.size >= MAX_CACHE_ENTRIES) {
|
|
25
28
|
const firstKey = fetchCache.keys().next().value;
|
|
26
29
|
if (firstKey)
|
|
27
30
|
fetchCache.delete(firstKey);
|
|
28
31
|
}
|
|
29
|
-
fetchCache.set(
|
|
32
|
+
fetchCache.set(key, { output, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
30
33
|
}
|
|
31
34
|
// ─── Execute ────────────────────────────────────────────────────────────────
|
|
32
|
-
async function execute(input,
|
|
35
|
+
async function execute(input, ctx) {
|
|
33
36
|
const { url, max_length } = input;
|
|
34
37
|
if (!url) {
|
|
35
38
|
return { output: 'Error: url is required', isError: true };
|
|
@@ -45,13 +48,17 @@ async function execute(input, _ctx) {
|
|
|
45
48
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
46
49
|
return { output: `Error: only http/https URLs are supported`, isError: true };
|
|
47
50
|
}
|
|
51
|
+
const maxLen = Math.min(max_length ?? MAX_BODY_BYTES, MAX_BODY_BYTES);
|
|
52
|
+
const key = cacheKey(url, maxLen);
|
|
48
53
|
// Check cache first
|
|
49
|
-
const cached = getCached(
|
|
54
|
+
const cached = getCached(key);
|
|
50
55
|
if (cached) {
|
|
51
56
|
return { output: cached + '\n\n(cached)' };
|
|
52
57
|
}
|
|
53
58
|
const controller = new AbortController();
|
|
54
59
|
const timeout = setTimeout(() => controller.abort(), 30_000);
|
|
60
|
+
const onAbort = () => controller.abort();
|
|
61
|
+
ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
55
62
|
try {
|
|
56
63
|
const response = await fetch(url, {
|
|
57
64
|
signal: controller.signal,
|
|
@@ -68,7 +75,6 @@ async function execute(input, _ctx) {
|
|
|
68
75
|
};
|
|
69
76
|
}
|
|
70
77
|
const contentType = response.headers.get('content-type') || '';
|
|
71
|
-
const maxLen = Math.min(max_length ?? MAX_BODY_BYTES, MAX_BODY_BYTES);
|
|
72
78
|
// Read body with size limit
|
|
73
79
|
const reader = response.body?.getReader();
|
|
74
80
|
if (!reader) {
|
|
@@ -106,11 +112,14 @@ async function execute(input, _ctx) {
|
|
|
106
112
|
output += '\n\n... (content truncated)';
|
|
107
113
|
}
|
|
108
114
|
// Cache successful responses
|
|
109
|
-
setCached(
|
|
115
|
+
setCached(key, output);
|
|
110
116
|
return { output };
|
|
111
117
|
}
|
|
112
118
|
catch (err) {
|
|
113
119
|
const msg = err instanceof Error ? err.message : String(err);
|
|
120
|
+
if (ctx.abortSignal.aborted) {
|
|
121
|
+
return { output: `Error: request aborted for ${url}`, isError: true };
|
|
122
|
+
}
|
|
114
123
|
if (msg.includes('abort')) {
|
|
115
124
|
return { output: `Error: request timed out after 30s for ${url}`, isError: true };
|
|
116
125
|
}
|
|
@@ -118,6 +127,7 @@ async function execute(input, _ctx) {
|
|
|
118
127
|
}
|
|
119
128
|
finally {
|
|
120
129
|
clearTimeout(timeout);
|
|
130
|
+
ctx.abortSignal.removeEventListener('abort', onAbort);
|
|
121
131
|
}
|
|
122
132
|
}
|
|
123
133
|
function stripHtml(html) {
|
package/dist/tools/write.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import os from 'node:os';
|
|
7
|
+
import { partiallyReadFiles } from './read.js';
|
|
7
8
|
function withTrailingSep(value) {
|
|
8
9
|
return value.endsWith(path.sep) ? value : value + path.sep;
|
|
9
10
|
}
|
|
@@ -84,6 +85,7 @@ async function execute(input, ctx) {
|
|
|
84
85
|
fs.mkdirSync(parentDir, { recursive: true });
|
|
85
86
|
const existed = fs.existsSync(resolved);
|
|
86
87
|
fs.writeFileSync(resolved, content, 'utf-8');
|
|
88
|
+
partiallyReadFiles.delete(resolved);
|
|
87
89
|
const lineCount = content.split('\n').length;
|
|
88
90
|
const byteCount = Buffer.byteLength(content, 'utf-8');
|
|
89
91
|
const sizeStr = byteCount >= 1024 ? `${(byteCount / 1024).toFixed(1)}KB` : `${byteCount}B`;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed config for Franklin's trading subsystem.
|
|
3
|
+
* Stored at ~/.blockrun/trading-config.json. Default written on first run.
|
|
4
|
+
*/
|
|
5
|
+
export interface TradingConfig {
|
|
6
|
+
version: 1;
|
|
7
|
+
watchlist: string[];
|
|
8
|
+
signals: {
|
|
9
|
+
rsi_oversold: number;
|
|
10
|
+
rsi_overbought: number;
|
|
11
|
+
};
|
|
12
|
+
model_tier: 'free' | 'cheap' | 'premium';
|
|
13
|
+
}
|
|
14
|
+
export declare const CONFIG_PATH: string;
|
|
15
|
+
/**
|
|
16
|
+
* Load config from disk. If missing, write defaults and return them.
|
|
17
|
+
* Returns the parsed config or throws on malformed JSON.
|
|
18
|
+
*/
|
|
19
|
+
export declare function loadTradingConfig(): TradingConfig;
|
|
20
|
+
/**
|
|
21
|
+
* Persist config back to disk.
|
|
22
|
+
*/
|
|
23
|
+
export declare function saveTradingConfig(cfg: TradingConfig): void;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed config for Franklin's trading subsystem.
|
|
3
|
+
* Stored at ~/.blockrun/trading-config.json. Default written on first run.
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
export const CONFIG_PATH = path.join(os.homedir(), '.blockrun', 'trading-config.json');
|
|
9
|
+
const DEFAULT_CONFIG = {
|
|
10
|
+
version: 1,
|
|
11
|
+
watchlist: ['BTC', 'ETH', 'SOL'],
|
|
12
|
+
signals: {
|
|
13
|
+
rsi_oversold: 30,
|
|
14
|
+
rsi_overbought: 70,
|
|
15
|
+
},
|
|
16
|
+
model_tier: 'cheap',
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Load config from disk. If missing, write defaults and return them.
|
|
20
|
+
* Returns the parsed config or throws on malformed JSON.
|
|
21
|
+
*/
|
|
22
|
+
export function loadTradingConfig() {
|
|
23
|
+
const dir = path.dirname(CONFIG_PATH);
|
|
24
|
+
if (!fs.existsSync(dir))
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
26
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
27
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2));
|
|
28
|
+
return { ...DEFAULT_CONFIG };
|
|
29
|
+
}
|
|
30
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
31
|
+
const parsed = JSON.parse(raw);
|
|
32
|
+
if (parsed.version !== 1) {
|
|
33
|
+
throw new Error(`Unsupported trading config version ${parsed.version} (expected 1)`);
|
|
34
|
+
}
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Persist config back to disk.
|
|
39
|
+
*/
|
|
40
|
+
export function saveTradingConfig(cfg) {
|
|
41
|
+
const dir = path.dirname(CONFIG_PATH);
|
|
42
|
+
if (!fs.existsSync(dir))
|
|
43
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
44
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
45
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface PriceData {
|
|
2
|
+
price: number;
|
|
3
|
+
change24h: number;
|
|
4
|
+
volume24h: number;
|
|
5
|
+
marketCap: number;
|
|
6
|
+
}
|
|
7
|
+
export interface OHLCVData {
|
|
8
|
+
closes: number[];
|
|
9
|
+
timestamps: number[];
|
|
10
|
+
}
|
|
11
|
+
export interface TrendingCoin {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
symbol: string;
|
|
15
|
+
marketCapRank: number | null;
|
|
16
|
+
}
|
|
17
|
+
export interface MarketCoin {
|
|
18
|
+
id: string;
|
|
19
|
+
symbol: string;
|
|
20
|
+
name: string;
|
|
21
|
+
price: number;
|
|
22
|
+
change24h: number;
|
|
23
|
+
marketCap: number;
|
|
24
|
+
volume24h: number;
|
|
25
|
+
}
|
|
26
|
+
export declare function resolveId(ticker: string): string;
|
|
27
|
+
export declare function getPrice(ticker: string): Promise<PriceData | string>;
|
|
28
|
+
export declare function getOHLCV(ticker: string, days?: number): Promise<OHLCVData | string>;
|
|
29
|
+
export declare function getTrending(): Promise<TrendingCoin[] | string>;
|
|
30
|
+
export declare function getMarketOverview(): Promise<MarketCoin[] | string>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const BASE = "https://api.coingecko.com/api/v3";
|
|
2
|
+
const UA = "franklin/3.3.0 (trading)";
|
|
3
|
+
const TIMEOUT = 10_000;
|
|
4
|
+
const TICKER_MAP = {
|
|
5
|
+
BTC: "bitcoin", ETH: "ethereum", SOL: "solana", BNB: "binancecoin", XRP: "ripple",
|
|
6
|
+
ADA: "cardano", DOGE: "dogecoin", AVAX: "avalanche-2", DOT: "polkadot", MATIC: "matic-network",
|
|
7
|
+
LINK: "chainlink", UNI: "uniswap", ATOM: "cosmos", LTC: "litecoin", NEAR: "near",
|
|
8
|
+
APT: "aptos", ARB: "arbitrum", OP: "optimism", SUI: "sui", SEI: "sei-network",
|
|
9
|
+
FIL: "filecoin", AAVE: "aave", MKR: "maker", SNX: "synthetix-network-token",
|
|
10
|
+
COMP: "compound-governance-token", INJ: "injective-protocol", TIA: "celestia",
|
|
11
|
+
PEPE: "pepe", WIF: "dogwifcoin", RENDER: "render-token",
|
|
12
|
+
};
|
|
13
|
+
const cache = new Map();
|
|
14
|
+
function cached(key, ttlMs, fn) {
|
|
15
|
+
const hit = cache.get(key);
|
|
16
|
+
if (hit && hit.expiry > Date.now())
|
|
17
|
+
return Promise.resolve(hit.data);
|
|
18
|
+
return fn().then(data => {
|
|
19
|
+
cache.set(key, { data, expiry: Date.now() + ttlMs });
|
|
20
|
+
return data;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const TTL_PRICE = 5 * 60_000;
|
|
24
|
+
const TTL_OHLCV = 60 * 60_000;
|
|
25
|
+
const TTL_TRENDING = 15 * 60_000;
|
|
26
|
+
// Fetch helper
|
|
27
|
+
async function geckofetch(path) {
|
|
28
|
+
const ctrl = new AbortController();
|
|
29
|
+
const timer = setTimeout(() => ctrl.abort(), TIMEOUT);
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
32
|
+
headers: { "User-Agent": UA },
|
|
33
|
+
signal: ctrl.signal,
|
|
34
|
+
});
|
|
35
|
+
if (res.status === 429)
|
|
36
|
+
return "rate-limited: CoinGecko 429 — retry later";
|
|
37
|
+
if (!res.ok)
|
|
38
|
+
return `CoinGecko error ${res.status}`;
|
|
39
|
+
return await res.json();
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
if (e instanceof DOMException && e.name === "AbortError")
|
|
43
|
+
return "request timed out";
|
|
44
|
+
return String(e);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function resolveId(ticker) {
|
|
51
|
+
return TICKER_MAP[ticker.toUpperCase()] ?? ticker.toLowerCase();
|
|
52
|
+
}
|
|
53
|
+
export async function getPrice(ticker) {
|
|
54
|
+
const id = resolveId(ticker);
|
|
55
|
+
return cached(`price:${id}`, TTL_PRICE, async () => {
|
|
56
|
+
const raw = await geckofetch(`/simple/price?ids=${id}&vs_currencies=usd&include_24hr_change=true&include_market_cap=true&include_24hr_vol=true`);
|
|
57
|
+
if (typeof raw === "string")
|
|
58
|
+
return raw;
|
|
59
|
+
const d = raw[id];
|
|
60
|
+
if (!d)
|
|
61
|
+
return `no data for ${ticker}`;
|
|
62
|
+
return {
|
|
63
|
+
price: d.usd,
|
|
64
|
+
change24h: d.usd_24h_change,
|
|
65
|
+
volume24h: d.usd_24h_vol,
|
|
66
|
+
marketCap: d.usd_market_cap,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export async function getOHLCV(ticker, days = 30) {
|
|
71
|
+
const id = resolveId(ticker);
|
|
72
|
+
return cached(`ohlcv:${id}:${days}`, TTL_OHLCV, async () => {
|
|
73
|
+
const raw = await geckofetch(`/coins/${id}/market_chart?vs_currency=usd&days=${days}&interval=daily`);
|
|
74
|
+
if (typeof raw === "string")
|
|
75
|
+
return raw;
|
|
76
|
+
const prices = raw.prices;
|
|
77
|
+
return {
|
|
78
|
+
timestamps: prices.map(p => p[0]),
|
|
79
|
+
closes: prices.map(p => p[1]),
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export async function getTrending() {
|
|
84
|
+
return cached("trending", TTL_TRENDING, async () => {
|
|
85
|
+
const raw = await geckofetch("/search/trending");
|
|
86
|
+
if (typeof raw === "string")
|
|
87
|
+
return raw;
|
|
88
|
+
const coins = raw.coins;
|
|
89
|
+
return coins.map(c => ({
|
|
90
|
+
id: c.item.id,
|
|
91
|
+
name: c.item.name,
|
|
92
|
+
symbol: c.item.symbol,
|
|
93
|
+
marketCapRank: c.item.market_cap_rank,
|
|
94
|
+
}));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
export async function getMarketOverview() {
|
|
98
|
+
return cached("markets", TTL_TRENDING, async () => {
|
|
99
|
+
const raw = await geckofetch("/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=20&page=1");
|
|
100
|
+
if (typeof raw === "string")
|
|
101
|
+
return raw;
|
|
102
|
+
return raw.map(c => ({
|
|
103
|
+
id: c.id,
|
|
104
|
+
symbol: c.symbol,
|
|
105
|
+
name: c.name,
|
|
106
|
+
price: c.current_price,
|
|
107
|
+
change24h: c.price_change_percentage_24h,
|
|
108
|
+
marketCap: c.market_cap,
|
|
109
|
+
volume24h: c.total_volume,
|
|
110
|
+
}));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface RSIResult {
|
|
2
|
+
value: number;
|
|
3
|
+
values: number[];
|
|
4
|
+
interpretation: 'oversold' | 'neutral' | 'overbought';
|
|
5
|
+
}
|
|
6
|
+
export interface MACDResult {
|
|
7
|
+
macd: number;
|
|
8
|
+
signal: number;
|
|
9
|
+
histogram: number;
|
|
10
|
+
trend: 'bullish' | 'bearish' | 'neutral';
|
|
11
|
+
}
|
|
12
|
+
export interface BollingerResult {
|
|
13
|
+
upper: number;
|
|
14
|
+
middle: number;
|
|
15
|
+
lower: number;
|
|
16
|
+
bandwidth: number;
|
|
17
|
+
position: 'above' | 'within' | 'below';
|
|
18
|
+
}
|
|
19
|
+
export interface VolatilityResult {
|
|
20
|
+
daily: number;
|
|
21
|
+
annualized: number;
|
|
22
|
+
interpretation: 'low' | 'medium' | 'high';
|
|
23
|
+
}
|
|
24
|
+
export declare function sma(data: number[], period: number): number;
|
|
25
|
+
export declare function ema(closes: number[], period: number): number[];
|
|
26
|
+
export declare function rsi(closes: number[], period?: number): RSIResult;
|
|
27
|
+
export declare function macd(closes: number[], fast?: number, slow?: number, signal?: number): MACDResult;
|
|
28
|
+
export declare function bollingerBands(closes: number[], period?: number, stdDev?: number): BollingerResult;
|
|
29
|
+
export declare function volatility(closes: number[], period?: number): VolatilityResult;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export function sma(data, period) {
|
|
2
|
+
if (data.length < period)
|
|
3
|
+
return NaN;
|
|
4
|
+
const slice = data.slice(data.length - period);
|
|
5
|
+
return slice.reduce((sum, v) => sum + v, 0) / period;
|
|
6
|
+
}
|
|
7
|
+
export function ema(closes, period) {
|
|
8
|
+
const result = new Array(closes.length).fill(NaN);
|
|
9
|
+
if (closes.length < period)
|
|
10
|
+
return result;
|
|
11
|
+
let sum = 0;
|
|
12
|
+
for (let i = 0; i < period; i++)
|
|
13
|
+
sum += closes[i];
|
|
14
|
+
result[period - 1] = sum / period;
|
|
15
|
+
const k = 2 / (period + 1);
|
|
16
|
+
for (let i = period; i < closes.length; i++) {
|
|
17
|
+
result[i] = closes[i] * k + result[i - 1] * (1 - k);
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
export function rsi(closes, period = 14) {
|
|
22
|
+
const values = new Array(closes.length).fill(NaN);
|
|
23
|
+
if (closes.length < period + 1) {
|
|
24
|
+
return { value: NaN, values, interpretation: 'neutral' };
|
|
25
|
+
}
|
|
26
|
+
const gains = [];
|
|
27
|
+
const losses = [];
|
|
28
|
+
for (let i = 1; i < closes.length; i++) {
|
|
29
|
+
const diff = closes[i] - closes[i - 1];
|
|
30
|
+
gains.push(diff > 0 ? diff : 0);
|
|
31
|
+
losses.push(diff < 0 ? -diff : 0);
|
|
32
|
+
}
|
|
33
|
+
let avgGain = gains.slice(0, period).reduce((s, v) => s + v, 0) / period;
|
|
34
|
+
let avgLoss = losses.slice(0, period).reduce((s, v) => s + v, 0) / period;
|
|
35
|
+
const computeRSI = (ag, al) => al === 0 ? 100 : 100 - 100 / (1 + ag / al);
|
|
36
|
+
values[period] = computeRSI(avgGain, avgLoss);
|
|
37
|
+
for (let i = period; i < gains.length; i++) {
|
|
38
|
+
avgGain = (avgGain * (period - 1) + gains[i]) / period;
|
|
39
|
+
avgLoss = (avgLoss * (period - 1) + losses[i]) / period;
|
|
40
|
+
values[i + 1] = computeRSI(avgGain, avgLoss);
|
|
41
|
+
}
|
|
42
|
+
const latest = values[values.length - 1];
|
|
43
|
+
const interpretation = latest < 30 ? 'oversold' : latest > 70 ? 'overbought' : 'neutral';
|
|
44
|
+
return { value: latest, values, interpretation };
|
|
45
|
+
}
|
|
46
|
+
export function macd(closes, fast = 12, slow = 26, signal = 9) {
|
|
47
|
+
const emaFast = ema(closes, fast);
|
|
48
|
+
const emaSlow = ema(closes, slow);
|
|
49
|
+
const macdLine = closes.map((_, i) => isNaN(emaFast[i]) || isNaN(emaSlow[i]) ? NaN : emaFast[i] - emaSlow[i]);
|
|
50
|
+
const validMacd = macdLine.filter((v) => !isNaN(v));
|
|
51
|
+
const signalLine = ema(validMacd, signal);
|
|
52
|
+
const padded = new Array(macdLine.length - validMacd.length)
|
|
53
|
+
.fill(NaN)
|
|
54
|
+
.concat(signalLine);
|
|
55
|
+
const histogram = macdLine.map((v, i) => isNaN(v) || isNaN(padded[i]) ? NaN : v - padded[i]);
|
|
56
|
+
const last = macdLine[macdLine.length - 1];
|
|
57
|
+
const lastSignal = padded[padded.length - 1];
|
|
58
|
+
const lastHist = histogram[histogram.length - 1];
|
|
59
|
+
const prevHist = histogram[histogram.length - 2];
|
|
60
|
+
let trend = 'neutral';
|
|
61
|
+
if (!isNaN(lastHist) && !isNaN(prevHist)) {
|
|
62
|
+
if (lastHist > 0 && lastHist > prevHist)
|
|
63
|
+
trend = 'bullish';
|
|
64
|
+
else if (lastHist < 0 && lastHist < prevHist)
|
|
65
|
+
trend = 'bearish';
|
|
66
|
+
}
|
|
67
|
+
return { macd: last, signal: lastSignal, histogram: lastHist, trend };
|
|
68
|
+
}
|
|
69
|
+
export function bollingerBands(closes, period = 20, stdDev = 2) {
|
|
70
|
+
if (closes.length < period) {
|
|
71
|
+
return {
|
|
72
|
+
upper: NaN,
|
|
73
|
+
middle: NaN,
|
|
74
|
+
lower: NaN,
|
|
75
|
+
bandwidth: NaN,
|
|
76
|
+
position: 'within',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const slice = closes.slice(closes.length - period);
|
|
80
|
+
const middle = slice.reduce((s, v) => s + v, 0) / period;
|
|
81
|
+
const variance = slice.reduce((s, v) => s + (v - middle) ** 2, 0) / period;
|
|
82
|
+
const sigma = Math.sqrt(variance);
|
|
83
|
+
const upper = middle + stdDev * sigma;
|
|
84
|
+
const lower = middle - stdDev * sigma;
|
|
85
|
+
const bandwidth = (upper - lower) / middle;
|
|
86
|
+
const price = closes[closes.length - 1];
|
|
87
|
+
const position = price > upper ? 'above' : price < lower ? 'below' : 'within';
|
|
88
|
+
return { upper, middle, lower, bandwidth, position };
|
|
89
|
+
}
|
|
90
|
+
export function volatility(closes, period = 14) {
|
|
91
|
+
if (closes.length < period + 1) {
|
|
92
|
+
return { daily: NaN, annualized: NaN, interpretation: 'medium' };
|
|
93
|
+
}
|
|
94
|
+
const returns = [];
|
|
95
|
+
const start = closes.length - period - 1;
|
|
96
|
+
for (let i = start + 1; i < closes.length; i++) {
|
|
97
|
+
returns.push(Math.log(closes[i] / closes[i - 1]));
|
|
98
|
+
}
|
|
99
|
+
const mean = returns.reduce((s, v) => s + v, 0) / returns.length;
|
|
100
|
+
const variance = returns.reduce((s, v) => s + (v - mean) ** 2, 0) / (returns.length - 1);
|
|
101
|
+
const daily = Math.sqrt(variance);
|
|
102
|
+
const annualized = daily * Math.sqrt(365);
|
|
103
|
+
const interpretation = annualized < 0.3 ? 'low' : annualized > 0.8 ? 'high' : 'medium';
|
|
104
|
+
return { daily, annualized, interpretation };
|
|
105
|
+
}
|