/**
* Mewtwo Image & JSON Combiner (v69 - Daily Summary Feature)
* * Description:
* Adds a new workflow to handle standalone JSON files from a '[REDACTED_DIR_NAME_D]'
* directory. Instead of pairing with a PNG, the script now calculates trade
* statistics from the JSON data and generates a new, standalone summary image.
* The file discovery logic is updated to handle both paired and standalone jobs.
* * Usage:
* node --expose-gc process_logs.js
*/
const fs = require('fs');
const path = require('path');
const sharp = require('sharp');
// --- CONFIGURATION ---
const ROOT_DIR = '.';
const OUTPUT_DIR = 'log_outputs';
const EXCLUDE_DIRS = ['log_outputs', 'node_modules', '.git'];
const PADDING = 10;
const VERTICAL_PADDING = 20;
const BACKGROUND_COLOR = '#141414';
const TEXT_COLOR = '#DCDCDC';
const TABLE_BACKGROUND_COLOR = '#2a2a2a';
const BID_COLOR = '#ff4d4d'; // Red
const BEARISH = BID_COLOR;
const ASK_COLOR = '#33cc33'; // Green
const BULLISH = ASK_COLOR;
const DEMAND_COLOR = '#4da6ff'; // Blue
const SUPPLY_COLOR = '#FFA500'; // Orange
const FONT_SIZE = 14;
const HEADER_FONT_SIZE = 18;
const LINE_HEIGHT = FONT_SIZE * 1.4;
const GRID_COLUMN_WIDTH = 180;
const GRID_COLUMN_GAP = 25;
const HEADER_TO_BOX_SPACING = LINE_HEIGHT * 0.75;
const SVG_MIN_HEIGHT_PX = 150;
const [REDACTED_STRATEGY_CONFIG] = {
'[REDACTED_INSTRUMENT_KEY]': {
[REDACTED_PARAM_KEY]: 1200
}
};
// --- SVG GENERATION LOGIC ---
function getColumnX(columnIndex) {
return PADDING + columnIndex * (GRID_COLUMN_WIDTH + GRID_COLUMN_GAP);
}
function getOrdinal(n) {
if (n > 3 && n < 21) return 'th';
switch (n % 10) {
case 1: return "st";
case 2: return "nd";
case 3: return "rd";
default: return "th";
}
}
function escapeHtml(text) {
if (typeof text !== 'string') return text;
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
function formatTimestamp(rawTimestamp) {
if (!rawTimestamp) return '';
const parsableTimestamp = rawTimestamp.replace(/\./g, '-');
const date = new Date(parsableTimestamp);
if (isNaN(date.getTime())) return rawTimestamp;
const options = { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true };
return date.toLocaleString('en-US', options);
}
function formatCandleTable(title, candleData, width, height) {
if (!candleData) return { svg: '', width: 0 };
let titleColor = TEXT_COLOR;
if (candleData.isIndecisive) {
if (candleData.close > candleData.open) titleColor = BULLISH;
else if (candleData.close < candleData.open) titleColor = BEARISH;
}
let tspanElements = `<tspan x="${PADDING}" dy="${LINE_HEIGHT}" font-weight="bold" fill="${titleColor}">${title}</tspan>`;
const indecisiveValueColor = candleData.isIndecisive ? BULLISH : BEARISH;
tspanElements += `<tspan x="${PADDING}" dy="${LINE_HEIGHT}">Indecisive: </tspan><tspan fill="${indecisiveValueColor}">${String(candleData.isIndecisive)}</tspan>`;
const otherDetails = [`Open: ${candleData.open}`, `Close: ${candleData.close}`, `High: ${candleData.high}`, `Low: ${candleData.low}`];
otherDetails.forEach(detail => tspanElements += `<tspan x="${PADDING}" dy="${LINE_HEIGHT}">${escapeHtml(detail)}</tspan>`);
const svg = `<g><rect x="0" y="0" width="${width}" height="${height}" fill="${TABLE_BACKGROUND_COLOR}" rx="5" /><text y="${FONT_SIZE}" font-family="monospace" font-size="${FONT_SIZE}" fill="${TEXT_COLOR}" xml:space="preserve">${tspanElements}</text></g>`;
return { svg, width: width + GRID_COLUMN_GAP };
}
function [REDACTED_formatStrategyTableA](volume, width, height, pairTimeframeKey) {
if (volume === undefined) return { svg: '', width: 0 };
let tspanElements = `<tspan x="${PADDING}" dy="${LINE_HEIGHT}" font-weight="bold">[REDACTED_HEADER]</tspan>`;
const config = [REDACTED_STRATEGY_CONFIG][pairTimeframeKey];
if (config) {
const volumeColor = volume >= config.[REDACTED_PARAM_KEY] ? BULLISH : BEARISH;
tspanElements += `<tspan x="${PADDING}" dy="${LINE_HEIGHT}" fill="${volumeColor}">${volume}</tspan>`;
} else {
tspanElements += `<tspan x="${PADDING}" dy="${LINE_HEIGHT}">${volume}</tspan>`;
}
const svg = `<g><rect x="0" y="0" width="${width}" height="${height}" fill="${TABLE_BACKGROUND_COLOR}" rx="5" /><text y="${FONT_SIZE}" font-family="monospace" font-size="${FONT_SIZE}" fill="${TEXT_COLOR}">${tspanElements}</text></g>`;
return { svg, width: width + GRID_COLUMN_GAP };
}
function [REDACTED_formatStrategyTableB](structure, width, height) {
if (!structure) return { svg: '', width: 0 };
let [REDACTED_color] = TEXT_COLOR;
if (structure === 'BEARISH') [REDACTED_color] = BEARISH;
else if (structure === 'BULLISH') [REDACTED_color] = BULLISH;
let tspanElements = `<tspan x="${PADDING}" dy="${LINE_HEIGHT}" font-weight="bold">[REDACTED_HEADER]</tspan>`;
tspanElements += `<tspan x="${PADDING}" dy="${LINE_HEIGHT}" fill="${[REDACTED_color]}">${structure}</tspan>`;
const svg = `<g><rect x="0" y="0" width="${width}" height="${height}" fill="${TABLE_BACKGROUND_COLOR}" rx="5" /><text y="${FONT_SIZE}" font-family="monospace" font-size="${FONT_SIZE}" fill="${TEXT_COLOR}">${tspanElements}</text></g>`;
return { svg, width: width + GRID_COLUMN_GAP };
}
function [REDACTED_formatStrategyTableC](structure, width, height) {
let zoneTypeText = '', zoneTypeColor = TEXT_COLOR;
if (structure === 'BULLISH') {
zoneTypeText = '[REDACTED_VALUE_A]';
zoneTypeColor = DEMAND_COLOR;
} else if (structure === 'BEARISH') {
zoneTypeText = '[REDACTED_VALUE_B]';
zoneTypeColor = SUPPLY_COLOR;
} else {
return { svg: '', width: 0 };
}
let tspanElements = `<tspan x="${PADDING}" dy="${LINE_HEIGHT}" font-weight="bold">[REDACTED_HEADER]</tspan>`;
tspanElements += `<tspan x="${PADDING}" dy="${LINE_HEIGHT}" fill="${zoneTypeColor}">${zoneTypeText}</tspan>`;
const svg = `<g><rect x="0" y="0" width="${width}" height="${height}" fill="${TABLE_BACKGROUND_COLOR}" rx="5" /><text y="${FONT_SIZE}" font-family="monospace" font-size="${FONT_SIZE}" fill="${TEXT_COLOR}">${tspanElements}</text></g>`;
return { svg, width: width + GRID_COLUMN_GAP };
}
function createDataSvg(jsonData, pairTimeframeKey, sourceDir) {
const rawTimestamp = jsonData.time || null;
const timestamp = rawTimestamp ? formatTimestamp(rawTimestamp) : null;
if (jsonData.time) delete jsonData.time;
const { [REDACTED_STRATEGY_LABEL_A]: [REDACTED_STRATEGY_VAR_A], ...details } = jsonData;
const [REDACTED_STRATEGY_VAR_B] = details.[REDACTED_STRATEGY_LABEL_B];
let svgElements = '';
let totalRequiredWidth = PADDING;
let currentY = VERTICAL_PADDING;
let columnIndex = 0;
if (timestamp) {
svgElements += '%%TIMESTAMP_PLACEHOLDER%%';
currentY += FONT_SIZE + VERTICAL_PADDING / 2;
}
const headerY = currentY + HEADER_FONT_SIZE;
svgElements += `<text x="${PADDING}" y="${headerY}" font-family="sans-serif" font-size="${HEADER_FONT_SIZE}" font-weight="bold" fill="${TEXT_COLOR}">[REDACTED_HEADER]</text>`;
currentY = headerY + HEADER_TO_BOX_SPACING;
const contextRowY = currentY;
columnIndex = 0;
const candleTableLineCount = 6;
const commonTableHeight = (candleTableLineCount * LINE_HEIGHT) + (VERTICAL_PADDING * 1.5);
if (details.[REDACTED_STRATEGY_LABEL_B]) {
const msResult = [REDACTED_formatStrategyTableB]([REDACTED_STRATEGY_VAR_B], GRID_COLUMN_WIDTH, commonTableHeight);
svgElements += `<g transform="translate(${getColumnX(columnIndex++)}, ${contextRowY})">${msResult.svg}</g>`;
}
if (sourceDir === '[REDACTED_DIR_NAME_A]' && details.[REDACTED_STRATEGY_LABEL_B]) {
const ztResult = [REDACTED_formatStrategyTableC]([REDACTED_STRATEGY_VAR_B], GRID_COLUMN_WIDTH, commonTableHeight);
if (ztResult.width > 0) svgElements += `<g transform="translate(${getColumnX(columnIndex++)}, ${contextRowY})">${ztResult.svg}</g>`;
}
if (details.[REDACTED_STRATEGY_LABEL_C] !== undefined) {
const ovResult = [REDACTED_formatStrategyTableA](details.[REDACTED_STRATEGY_LABEL_C], GRID_COLUMN_WIDTH, commonTableHeight, pairTimeframeKey);
svgElements += `<g transform="translate(${getColumnX(columnIndex++)}, ${contextRowY})">${ovResult.svg}</g>`;
delete details.[REDACTED_STRATEGY_LABEL_C];
}
if (details.[REDACTED_STRATEGY_LABEL_D]) {
const icResult = formatCandleTable('[REDACTED_HEADER]', details.[REDACTED_STRATEGY_LABEL_D], GRID_COLUMN_WIDTH, commonTableHeight);
svgElements += `<g transform="translate(${getColumnX(columnIndex++)}, ${contextRowY})">${icResult.svg}</g>`;
delete details.[REDACTED_STRATEGY_LABEL_D];
}
delete details.[REDACTED_STRATEGY_LABEL_B];
currentY = contextRowY + commonTableHeight + VERTICAL_PADDING;
totalRequiredWidth = Math.max(totalRequiredWidth, getColumnX(columnIndex - 1) + GRID_COLUMN_WIDTH);
if ([REDACTED_STRATEGY_VAR_A] && Object.keys([REDACTED_STRATEGY_VAR_A]).length > 0) {
const [REDACTED_header_Y_A] = currentY + HEADER_FONT_SIZE;
svgElements += `<text x="${PADDING}" y="${[REDACTED_header_Y_A]}" font-family="sans-serif" font-size="${HEADER_FONT_SIZE}" font-weight="bold" fill="${TEXT_COLOR}">[REDACTED_HEADER]</text>`;
currentY = [REDACTED_header_Y_A] + HEADER_TO_BOX_SPACING;
const [REDACTED_tables_Y_A] = currentY;
columnIndex = 0;
const tableKeys = Object.keys([REDACTED_STRATEGY_VAR_A]).sort((a, b) => parseInt(b) - parseInt(a));
let maxLines = tableKeys.reduce((max, key) => Math.max(max, 2 + Math.max([REDACTED_STRATEGY_VAR_A][key].bids.length, [REDACTED_STRATEGY_VAR_A][key].asks.length)), 0);
const uniformTableHeight = (maxLines * LINE_HEIGHT) + (VERTICAL_PADDING * 1.5);
for (const key of tableKeys) {
const number = parseInt(key);
const title = (number === 1) ? '[REDACTED_HEADER]' : `${number - 1}${getOrdinal(number - 1)} Preceding Candle`;
let tableTspanElements = `<tspan x="${PADDING}" dy="${LINE_HEIGHT}" font-weight="bold">${title}</tspan>`;
const asksX = PADDING + 80;
tableTspanElements += `<tspan x="${PADDING}" dy="${LINE_HEIGHT}" text-decoration="underline">Bids</tspan><tspan x="${asksX}" text-decoration="underline">Asks</tspan>`;
const { bids, asks } = [REDACTED_STRATEGY_VAR_A][key];
for (let i = 0; i < Math.max(bids.length, asks.length); i++) {
let bidColor = TEXT_COLOR, askColor = TEXT_COLOR;
if ([REDACTED_STRATEGY_VAR_B] === 'BEARISH' && i === 0) { bidColor = BEARISH; askColor = ASK_COLOR; }
if ([REDACTED_STRATEGY_VAR_B] === 'BULLISH' && i === Math.max(bids.length, asks.length) - 1) { bidColor = BEARISH; askColor = ASK_COLOR; }
tableTspanElements += `<tspan x="${PADDING}" dy="${LINE_HEIGHT}" fill="${bidColor}">${escapeHtml(bids[i] ?? '')}</tspan><tspan x="${asksX}" fill="${askColor}">${escapeHtml(asks[i] ?? '')}</tspan>`;
}
svgElements += `<g transform="translate(${getColumnX(columnIndex++)}, ${[REDACTED_tables_Y_A]})"><rect x="0" y="0" width="${GRID_COLUMN_WIDTH}" height="${uniformTableHeight}" fill="${TABLE_BACKGROUND_COLOR}" rx="5" /><text y="${FONT_SIZE}" font-family="monospace" font-size="${FONT_SIZE}" fill="${TEXT_COLOR}">${tableTspanElements}</text></g>`;
}
currentY = [REDACTED_tables_Y_A] + uniformTableHeight;
totalRequiredWidth = Math.max(totalRequiredWidth, getColumnX(columnIndex - 1) + GRID_COLUMN_WIDTH);
}
const [REDACTED_timeframe_list] = ['M1', 'M5', 'M15', 'M30', 'H1'];
const [REDACTED_candle_list] = [];
const allDetails = { ...details, ...(details.[REDACTED_STRATEGY_LABEL_E] || {}) };
for (const tf of [REDACTED_timeframe_list]) {
if (allDetails[tf]) {
[REDACTED_candle_list].push({ title: `${tf} Candle`, data: allDetails[tf] });
delete allDetails[tf];
}
}
delete details.[REDACTED_STRATEGY_LABEL_E];
if ([REDACTED_candle_list].length > 0) {
currentY += VERTICAL_PADDING * 1.5;
const [REDACTED_header_Y_B] = currentY + HEADER_FONT_SIZE;
svgElements += `<text x="${PADDING}" y="${[REDACTED_header_Y_B]}" font-family="sans-serif" font-size="${HEADER_FONT_SIZE}" font-weight="bold" fill="${TEXT_COLOR}">[REDACTED_HEADER]</text>`;
currentY = [REDACTED_header_Y_B] + HEADER_TO_BOX_SPACING;
const [REDACTED_tables_Y_B] = currentY;
columnIndex = 0;
for (const candle of [REDACTED_candle_list]) {
const result = formatCandleTable(candle.title, candle.data, GRID_COLUMN_WIDTH, commonTableHeight);
svgElements += `<g transform="translate(${getColumnX(columnIndex++)}, ${[REDACTED_tables_Y_B]})">${result.svg}</g>`;
}
currentY = [REDACTED_tables_Y_B] + commonTableHeight;
totalRequiredWidth = Math.max(totalRequiredWidth, getColumnX(columnIndex - 1) + GRID_COLUMN_WIDTH);
}
if (sourceDir === '[REDACTED_DIR_NAME_B]' && details.[REDACTED_STRATEGY_LABEL_F]) {
currentY += VERTICAL_PADDING * 1.5;
const reasonHeaderY = currentY + HEADER_FONT_SIZE;
svgElements += `<text x="${PADDING}" y="${reasonHeaderY}" font-family="sans-serif" font-size="${HEADER_FONT_SIZE}" font-weight="bold" fill="${TEXT_COLOR}">[REDACTED_HEADER]</text>`;
currentY = reasonHeaderY + HEADER_TO_BOX_SPACING;
const boxWidth = (GRID_COLUMN_WIDTH * 2) + GRID_COLUMN_GAP;
const boxHeight = (1 * LINE_HEIGHT) + VERTICAL_PADDING;
const reasonBoxY = currentY;
let tspanElements = `<tspan x="${PADDING}" dy="${VERTICAL_PADDING}">${escapeHtml(details.[REDACTED_STRATEGY_LABEL_F])}</tspan>`;
svgElements += `<g transform="translate(${getColumnX(0)}, ${reasonBoxY})"><rect x="0" y="0" width="${boxWidth}" height="${boxHeight}" fill="${TABLE_BACKGROUND_COLOR}" rx="5" /><text y="0" font-family="monospace" font-size="${FONT_SIZE}" fill="${TEXT_COLOR}">${tspanElements}</text></g>`;
currentY = reasonBoxY + boxHeight + VERTICAL_PADDING;
totalRequiredWidth = Math.max(totalRequiredWidth, PADDING + boxWidth);
delete details.[REDACTED_STRATEGY_LABEL_F];
}
const [REDACTED_STRATEGY_VAR_C] = details.[REDACTED_STRATEGY_LABEL_G];
if ([REDACTED_STRATEGY_VAR_C]) {
currentY += VERTICAL_PADDING * 1.5;
const tradeSectionHeader = sourceDir === '[REDACTED_DIR_NAME_C]' ? 'Active Trade' : 'Prospective Trade';
const tradeHeaderY = currentY + HEADER_FONT_SIZE;
svgElements += `<text x="${PADDING}" y="${tradeHeaderY}" font-family="sans-serif" font-size="${HEADER_FONT_SIZE}" font-weight="bold" fill="${TEXT_COLOR}">${tradeSectionHeader}</text>`;
currentY = tradeHeaderY + HEADER_TO_BOX_SPACING;
const dynamicLimitColor = ([REDACTED_STRATEGY_VAR_B] === 'BEARISH') ? SUPPLY_COLOR : DEMAND_COLOR;
const tradeLines = [];
if ([REDACTED_STRATEGY_VAR_C].[REDACTED_PROP_A] !== undefined) tradeLines.push({ label: '[REDACTED_LABEL]: ', value: [REDACTED_STRATEGY_VAR_C].[REDACTED_PROP_A], color: dynamicLimitColor });
if ([REDACTED_STRATEGY_VAR_C].[REDACTED_PROP_B] !== undefined) tradeLines.push({ label: '[REDACTED_LABEL]: ', value: [REDACTED_STRATEGY_VAR_C].[REDACTED_PROP_B], color: BULLISH });
if ([REDACTED_STRATEGY_VAR_C].[REDACTED_PROP_C] !== undefined) tradeLines.push({ label: '[REDACTED_LABEL]: ', value: [REDACTED_STRATEGY_VAR_C].[REDACTED_PROP_C], color: BEARISH });
if ([REDACTED_STRATEGY_VAR_C].[REDACTED_PROP_D] !== undefined) tradeLines.push({ label: '[REDACTED_LABEL]: ', value: [REDACTED_STRATEGY_VAR_C].[REDACTED_PROP_D] });
if ([REDACTED_STRATEGY_VAR_C].[REDACTED_PROP_E] !== undefined) tradeLines.push({ label: '[REDACTED_LABEL]: ', value: [REDACTED_STRATEGY_VAR_C].[REDACTED_PROP_E] });
if ([REDACTED_STRATEGY_VAR_C].[REDACTED_PROP_F] !== undefined) tradeLines.push({ label: '[REDACTED_LABEL]: ', value: [REDACTED_STRATEGY_VAR_C].[REDACTED_PROP_F] });
tradeLines.unshift({ label: '[REDACTED_HEADER]', isTitle: true });
const boxHeight = (tradeLines.length * LINE_HEIGHT) + VERTICAL_PADDING;
const boxWidth = GRID_COLUMN_WIDTH;
const tradeBoxY = currentY;
let tspanElements = '';
tradeLines.forEach((line, index) => {
const dy = index === 0 ? VERTICAL_PADDING : LINE_HEIGHT;
if (line.isTitle) tspanElements += `<tspan x="${PADDING}" dy="${dy}" font-weight="bold">${escapeHtml(line.label)}</tspan>`;
else tspanElements += `<tspan x="${PADDING}" dy="${dy}">${escapeHtml(line.label)}</tspan><tspan${line.color ? ` fill="${line.color}"` : ''}>${escapeHtml(line.value)}</tspan>`;
});
svgElements += `<g transform="translate(${getColumnX(0)}, ${tradeBoxY})"><rect x="0" y="0" width="${boxWidth}" height="${boxHeight}" fill="${TABLE_BACKGROUND_COLOR}" rx="5" /><text y="0" font-family="monospace" font-size="${FONT_SIZE}" fill="${TEXT_COLOR}" xml:space="preserve">${tspanElements}</text></g>`;
currentY = tradeBoxY + boxHeight + VERTICAL_PADDING;
totalRequiredWidth = Math.max(totalRequiredWidth, PADDING + boxWidth);
delete details.[REDACTED_STRATEGY_LABEL_G];
}
const remainingDetailsText = JSON.stringify(details, null, 2);
if (remainingDetailsText !== '{}') {
let detailsTspanElements = `<tspan x="0" dy="0" font-weight="bold">Remaining Details (JSON)</tspan>`;
const detailLines = remainingDetailsText.split('\n');
detailLines.forEach(line => detailsTspanElements += `<tspan x="${PADDING}" dy="${LINE_HEIGHT}">${escapeHtml(line)}</tspan>`);
const detailsTextY = currentY + FONT_SIZE;
svgElements += `<text x="${PADDING}" y="${detailsTextY}" font-family="monospace" font-size="${FONT_SIZE}" fill="${TEXT_COLOR}">${detailsTspanElements}</text>`;
currentY = detailsTextY + ((detailLines.length + 1) * LINE_HEIGHT);
}
return {
svgContent: svgElements,
calculatedWidth: totalRequiredWidth + PADDING,
calculatedHeight: currentY + VERTICAL_PADDING + SVG_MIN_HEIGHT_PX,
timestamp
};
}
// --- CORE SCRIPT LOGIC ---
async function createCombinedImage(pngPath, jsonPath) {
try {
const baseName = path.basename(pngPath, path.extname(pngPath));
const parts = baseName.split('_');
let pairTimeframeKey = (parts.length >= 2) ? `${parts[0].toUpperCase()}_${parts[1]}` : '';
const sourceDir = path.basename(path.dirname(pngPath));
const jsonContent = fs.readFileSync(jsonPath, 'utf8').replace(/^\uFEFF/, '');
const jsonData = JSON.parse(jsonContent);
const cleanPngBuffer = await sharp(pngPath).png().toBuffer();
const metadata = await sharp(cleanPngBuffer).metadata();
if (!metadata?.width || !metadata?.height) throw new Error('Failed to read valid dimensions from sanitized image.');
const { svgContent, calculatedWidth, calculatedHeight, timestamp } = createDataSvg(jsonData, pairTimeframeKey, sourceDir);
let finalSvgContent = svgContent;
if (timestamp) {
const timestampY = VERTICAL_PADDING + FONT_SIZE;
const timestampX = calculatedWidth - PADDING;
const timestampSvg = `<text x="${timestampX}" y="${timestampY}" font-family="monospace" font-size="${FONT_SIZE}" fill="${TEXT_COLOR}" text-anchor="end">${escapeHtml(timestamp)}</text>`;
finalSvgContent = svgContent.replace('%%TIMESTAMP_PLACEHOLDER%%', timestampSvg);
}
const newWidth = metadata.width + calculatedWidth + PADDING;
const newHeight = Math.ceil(Math.max(metadata.height + VERTICAL_PADDING * 2, calculatedHeight));
const finalSvg = `<svg width="${calculatedWidth}" height="${newHeight}" xmlns="http://www.w3.org/2000/svg">${finalSvgContent}</svg>`;
const outputBuffer = await sharp({ create: { width: newWidth, height: newHeight, channels: 4, background: BACKGROUND_COLOR } })
.composite([{ input: cleanPngBuffer, top: VERTICAL_PADDING, left: PADDING }, { input: Buffer.from(finalSvg), top: 0, left: metadata.width + PADDING }])
.png().toBuffer();
const relativeDir = path.dirname(path.relative(ROOT_DIR, pngPath));
const outputSubfolder = path.join(OUTPUT_DIR, relativeDir);
fs.mkdirSync(outputSubfolder, { recursive: true });
const outputPath = path.join(outputSubfolder, `${path.basename(pngPath, path.extname(pngPath))}.png`);
fs.writeFileSync(outputPath, outputBuffer);
console.log(` ā
Created: ${outputPath}`);
} catch (e) {
console.error(` ā Failed to process ${path.basename(pngPath)}: ${e.message}`);
}
}
// --- UPDATED FUNCTION FOR DAILY SUMMARIES ---
async function createDailySummaryImage(jsonPath) {
try {
const jsonContent = fs.readFileSync(jsonPath, 'utf8');
const jsonData = JSON.parse(jsonContent);
// --- 1. Process data and calculate totals ---
const profitOrders = [];
const lossOrders = [];
let totalProfit = 0;
let totalLoss = 0;
if (jsonData.[REDACTED_STRATEGY_LABEL_H]) {
for (const order of jsonData.[REDACTED_STRATEGY_LABEL_H]) {
// Filter to only include actual BUY and SELL trades
if (order.type === 'BUY' || order.type === 'SELL') {
if (order.profit >= 0) {
profitOrders.push(order);
totalProfit += order.profit;
} else {
lossOrders.push(order);
totalLoss += order.profit;
}
}
}
}
const netProfit = totalProfit + totalLoss;
// --- 2. Dynamically Generate SVG Content ---
const cellLineCount = 4;
const cellHeight = (cellLineCount * LINE_HEIGHT) + (PADDING * 2);
const cellSpacing = 10;
const maxTradesInColumn = Math.max(profitOrders.length, lossOrders.length, 1);
const mainHeaderY = VERTICAL_PADDING + HEADER_FONT_SIZE;
const columnTitlesY = mainHeaderY + (LINE_HEIGHT * 1.75);
const cellsStartY = columnTitlesY + HEADER_TO_BOX_SPACING;
const totalColumnContentHeight = (maxTradesInColumn * cellHeight) + (Math.max(0, maxTradesInColumn - 1) * cellSpacing);
// NEW: Calculate position and add height for the summary section
const summaryStartY = cellsStartY + totalColumnContentHeight + VERTICAL_PADDING * 1.5;
const summaryHeight = (3 * LINE_HEIGHT); // 3 lines of text
const height = summaryStartY + summaryHeight + VERTICAL_PADDING;
const width = (GRID_COLUMN_WIDTH * 2) + GRID_COLUMN_GAP + (PADDING * 2);
let svgContent = '';
const timestamp = formatTimestamp(jsonData.time);
// Main Header
svgContent += `<text x="${PADDING}" y="${mainHeaderY}" font-family="sans-serif" font-size="${HEADER_FONT_SIZE}" font-weight="bold" fill="${TEXT_COLOR}">[REDACTED_HEADER]</text>`;
svgContent += `<text x="${width - PADDING}" y="${mainHeaderY}" font-family="monospace" font-size="${FONT_SIZE}" fill="${TEXT_COLOR}" text-anchor="end">${timestamp}</text>`;
const createTradeCell = (order) => {
const profitColor = order.profit >= 0 ? BULLISH : BEARISH;
const profitLabel = order.profit >= 0 ? 'Profit:' : 'Loss:';
const profitValue = Math.abs(order.profit).toFixed(2);
const labelX = PADDING;
const valueX = PADDING + 75;
let tspan = `<tspan x="${labelX}" font-weight="bold">${order.type}</tspan>`;
tspan += `<tspan x="${labelX}" dy="${LINE_HEIGHT}">Open:</tspan><tspan x="${valueX}">${order.openPrice}</tspan>`;
tspan += `<tspan x="${labelX}" dy="${LINE_HEIGHT}">Close:</tspan><tspan x="${valueX}">${order.closePrice}</tspan>`;
tspan += `<tspan x="${labelX}" dy="${LINE_HEIGHT}">${profitLabel}</tspan><tspan x="${valueX}" fill="${profitColor}">${profitValue}</tspan>`;
const initialTextY = PADDING + FONT_SIZE;
return `<g>
<rect x="0" y="0" width="${GRID_COLUMN_WIDTH}" height="${cellHeight}" fill="${TABLE_BACKGROUND_COLOR}" rx="5" />
<text y="${initialTextY}" font-family="monospace" font-size="${FONT_SIZE}" fill="${TEXT_COLOR}">${tspan}</text>
</g>`;
};
const createColumn = (title, orders, titleColor, columnIndex) => {
let columnSvg = '';
const columnX = getColumnX(columnIndex);
columnSvg += `<text x="${columnX + PADDING}" y="${columnTitlesY}" font-family="sans-serif" font-size="${HEADER_FONT_SIZE}" fill="${titleColor}">${title}</text>`;
let currentY = cellsStartY;
for (const order of orders) {
const cellSvg = createTradeCell(order);
columnSvg += `<g transform="translate(${columnX}, ${currentY})">${cellSvg}</g>`;
currentY += cellHeight + cellSpacing;
}
return columnSvg;
};
svgContent += createColumn('WINS', profitOrders, BULLISH, 0);
svgContent += createColumn('LOSSES', lossOrders, BEARISH, 1);
// NEW: Add Summary Section at the bottom
const summaryLabelX = PADDING;
const summaryValueX = PADDING + 150;
let summaryTspan = `<tspan x="${summaryLabelX}">Total Profits:</tspan><tspan x="${summaryValueX}" fill="${BULLISH}">${totalProfit.toFixed(2)}</tspan>`;
summaryTspan += `<tspan x="${summaryLabelX}" dy="${LINE_HEIGHT}">Total Losses:</tspan><tspan x="${summaryValueX}" fill="${BEARISH}">${Math.abs(totalLoss).toFixed(2)}</tspan>`;
const netColor = netProfit >= 0 ? BULLISH : BEARISH;
summaryTspan += `<tspan x="${summaryLabelX}" dy="${LINE_HEIGHT}">Net Profit/Loss:</tspan><tspan x="${summaryValueX}" fill="${netColor}">${netProfit.toFixed(2)}</tspan>`;
svgContent += `<text y="${summaryStartY + FONT_SIZE}" font-family="monospace" font-size="${FONT_SIZE}" fill="${TEXT_COLOR}">${summaryTspan}</text>`;
const finalSvg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="100%" height="100%" fill="${BACKGROUND_COLOR}" />
${svgContent}
</svg>`;
// --- 3. Save as PNG (No changes here) ---
const outputBuffer = await sharp(Buffer.from(finalSvg)).png().toBuffer();
const relativeDir = path.dirname(path.relative(ROOT_DIR, jsonPath));
const outputSubfolder = path.join(OUTPUT_DIR, relativeDir);
fs.mkdirSync(outputSubfolder, { recursive: true });
const outputPath = path.join(outputSubfolder, `${path.basename(jsonPath, '.json')}.png`);
fs.writeFileSync(outputPath, outputBuffer);
console.log(` ā
Created Log: ${outputPath}`);
} catch (e) {
console.error(` ā Failed to process summary for ${path.basename(jsonPath)}: ${e.message}`);
}
}
function setupOutputDirectory() {
if (fs.existsSync(OUTPUT_DIR)) {
fs.readdirSync(OUTPUT_DIR).forEach(item => {
if (item !== '.obsidian') fs.rmSync(path.join(OUTPUT_DIR, item), { recursive: true, force: true });
});
}
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
console.log(`Directory '${OUTPUT_DIR}' is ready.`);
}
/**
* UPDATED: Handles both paired files and standalone JSONs in specific folders.
*/
function findFilePairs() {
const pairs = {};
console.log(`Scanning for .png/.json pairs and summary jobs in '${path.resolve(ROOT_DIR)}'...`);
function scan(dir) {
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
if (EXCLUDE_DIRS.includes(item)) continue;
if (fs.statSync(fullPath).isDirectory()) {
scan(fullPath);
} else {
const ext = path.extname(fullPath);
const relativePath = path.relative(ROOT_DIR, fullPath);
const key = path.join(path.dirname(relativePath), path.basename(relativePath, ext)).replace(/\\/g, '/');
const dirName = path.basename(path.dirname(fullPath));
// NEW: Logic for standalone summary jobs
if (dirName === '[REDACTED_DIR_NAME_D]' && ext === '.json') {
pairs[key] = {
type: 'summary',
'.json': fullPath
};
// Existing logic for paired jobs
} else if (ext === '.png' || ext === '.json') {
if (!pairs[key]) pairs[key] = { type: 'pair' };
pairs[key][ext] = fullPath;
}
}
}
}
scan(ROOT_DIR);
// UPDATED: Filter for both complete pairs and summary jobs
return Object.values(pairs).filter(p => (p.type === 'pair' && p['.png'] && p['.json']) || p.type === 'summary');
}
async function main() {
setupOutputDirectory();
const jobs = findFilePairs(); // Renamed to 'jobs' for clarity
if (jobs.length === 0) {
console.log("No matching .png/.json pairs or summary jobs found.");
return;
}
console.log(`\nFound ${jobs.length} jobs. Starting processing...\n`);
for (let i = 0; i < jobs.length; i++) {
const job = jobs[i];
console.log(`Processing job ${i + 1}/${jobs.length}: ${path.basename(job['.json'])}`);
// UPDATED: Route job based on its type
if (job.type === 'summary') {
await createDailySummaryImage(job['.json']);
} else if (job.type === 'pair') {
await createCombinedImage(job['.png'], job['.json']);
}
if (global.gc) global.gc();
}
console.log(`\nš Finished! Processed ${jobs.length} jobs.`);
}
(async () => {
try {
await main();
} catch (error) {
console.error(`ā A critical error occurred: ${error.message}`);
}
})();