feat: add price chart to simulation and unit tests

Unit tests (13 passing):
- Kline fetching and processing
- Price drop condition triggers buy
- Stop loss and take profit risk management
- Multiple positions (buy again after sell)
- Max candles limit
- Stop interruption handling

Frontend:
- SignalChart now shows price movement even before signals
- Shows candle count even with no signals
- Chart displays buy/sell markers when signals exist
- Canvas-based chart with gradient fill

Backend:
- Simulation stores klines for chart display
- Returns klines in API response
- Simplified simulation run (no periodic saving)
This commit is contained in:
shokollm
2026-04-12 02:42:52 +00:00
parent ce8a29c0a4
commit 6a20cc174f
11 changed files with 503 additions and 218 deletions

View File

@@ -7,6 +7,9 @@
"": {
"name": "frontend",
"version": "0.0.1",
"dependencies": {
"chart.js": "^4.5.1"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.57.0",
@@ -101,6 +104,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
@@ -569,6 +578,18 @@
"node": ">= 0.4"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",

View File

@@ -19,5 +19,8 @@
"svelte-check": "^4.4.6",
"typescript": "^6.0.2",
"vite": "^8.0.7"
},
"dependencies": {
"chart.js": "^4.5.1"
}
}

View File

@@ -113,15 +113,16 @@ export interface Simulation {
id: string;
bot_id: string;
started_at: string;
status: 'running' | 'stopped';
status: 'running' | 'stopped' | 'completed';
config: SimulationConfig;
signals: Signal[] | null;
klines?: { time: number; close: number }[];
}
export interface SimulationConfig {
token: string;
interval_seconds: number;
auto_execute: boolean;
chain?: string;
kline_interval?: string;
}
export interface Signal {

View File

@@ -1,155 +1,176 @@
<script lang="ts">
import type { Signal } from '$lib/api';
import { onMount } from 'svelte';
interface Props {
signals: Signal[];
signals?: Signal[];
klines?: { time: number; close: number }[];
height?: number;
}
let { signals, height = 200 }: Props = $props();
let { signals = [], klines = [], height = 200 }: Props = $props();
let width = $state(800);
let containerEl: HTMLDivElement;
let canvasEl: HTMLCanvasElement;
$effect(() => {
onMount(() => {
if (containerEl) {
width = containerEl.clientWidth;
drawChart();
}
});
function getSignalPosition(signal: Signal, index: number, total: number): { x: number; y: number } {
const padding = 30;
const chartWidth = width - padding * 2;
const chartHeight = height - padding * 2;
const x = padding + (index / Math.max(total - 1, 1)) * chartWidth;
const priceRange = getPriceRange();
const normalizedPrice = priceRange.min === priceRange.max ? 0.5 :
(signal.price - priceRange.min) / (priceRange.max - priceRange.min);
const y = padding + (1 - normalizedPrice) * chartHeight;
return { x, y };
}
function getPriceRange(): { min: number; max: number } {
if (signals.length === 0) return { min: 0, max: 1 };
const prices = signals.map(s => s.price);
const min = Math.min(...prices);
const max = Math.max(...prices);
const padding = (max - min) * 0.1 || 1;
return { min: min - padding, max: max + padding };
}
function getSignalColor(signal: Signal): string {
switch (signal.signal_type) {
case 'buy': return '#22c55e';
case 'sell': return '#ef4444';
case 'hold': return '#fbbf24';
default: return '#888';
$effect(() => {
if (canvasEl && (signals.length > 0 || klines.length > 0)) {
drawChart();
}
}
});
function getYAxisLabels(): string[] {
const range = getPriceRange();
const step = (range.max - range.min) / 4;
return [
range.max.toFixed(6),
(range.max - step).toFixed(6),
(range.min + step).toFixed(6),
range.min.toFixed(6)
];
}
function drawChart() {
if (!canvasEl) return;
const ctx = canvasEl.getContext('2d');
if (!ctx) return;
function getXAxisLabels(): string[] {
if (signals.length === 0) return [];
const step = Math.max(1, Math.floor(signals.length / 5));
const labels: string[] = [];
for (let i = 0; i < signals.length; i += step) {
labels.push(new Date(signals[i].created_at).toLocaleTimeString());
const dpr = window.devicePixelRatio || 1;
canvasEl.width = width * dpr;
canvasEl.height = height * dpr;
ctx.scale(dpr, dpr);
// Clear
ctx.clearRect(0, 0, width, height);
// Get price data
const prices = klines.length > 0
? klines.map(k => k.close)
: signals.map(s => s.price);
if (prices.length === 0) return;
const padding = { top: 20, right: 20, bottom: 30, left: 60 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
// Price range with padding
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const priceRange = maxPrice - minPrice || 1;
const paddedMin = minPrice - priceRange * 0.1;
const paddedMax = maxPrice + priceRange * 0.1;
function priceToY(price: number): number {
return padding.top + (1 - (price - paddedMin) / (paddedMax - paddedMin)) * chartHeight;
}
function indexToX(index: number): number {
return padding.left + (index / (prices.length - 1 || 1)) * chartWidth;
}
// Draw grid lines
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = padding.top + (i / 4) * chartHeight;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
}
// Draw Y axis labels
ctx.fillStyle = '#666';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
for (let i = 0; i <= 4; i++) {
const price = paddedMax - (i / 4) * (paddedMax - paddedMin);
const y = padding.top + (i / 4) * chartHeight + 4;
ctx.fillText('$' + price.toFixed(6), padding.left - 5, y);
}
// Draw price line
ctx.beginPath();
ctx.strokeStyle = '#667eea';
ctx.lineWidth = 2;
for (let i = 0; i < prices.length; i++) {
const x = indexToX(i);
const y = priceToY(prices[i]);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Fill area under line
ctx.lineTo(indexToX(prices.length - 1), padding.top + chartHeight);
ctx.lineTo(indexToX(0), padding.top + chartHeight);
ctx.closePath();
const gradient = ctx.createLinearGradient(0, padding.top, 0, padding.top + chartHeight);
gradient.addColorStop(0, 'rgba(102, 126, 234, 0.2)');
gradient.addColorStop(1, 'rgba(102, 126, 234, 0)');
ctx.fillStyle = gradient;
ctx.fill();
// Draw signal markers
if (signals.length > 0) {
// Draw line to each signal point
signals.forEach((signal) => {
const signalIndex = klines.length > 0
? klines.findIndex(k => Math.abs(k.close - signal.price) < 0.0001)
: signals.indexOf(signal);
if (signalIndex >= 0) {
const x = indexToX(signalIndex);
const y = priceToY(signal.price);
// Vertical line from top
ctx.beginPath();
ctx.strokeStyle = signal.signal_type === 'buy' ? '#22c55e' : '#ef4444';
ctx.setLineDash([4, 4]);
ctx.moveTo(x, padding.top);
ctx.lineTo(x, y);
ctx.stroke();
ctx.setLineDash([]);
// Signal dot
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.fillStyle = signal.signal_type === 'buy' ? '#22c55e' : '#ef4444';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
}
});
}
// Legend
if (signals.length > 0) {
const buyCount = signals.filter(s => s.signal_type === 'buy').length;
const sellCount = signals.filter(s => s.signal_type === 'sell').length;
ctx.fillStyle = '#888';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`📈 ${buyCount} Buy | ${sellCount} Sell | ${prices.length} Candles`, width / 2, height - 8);
} else if (klines.length > 0) {
ctx.fillStyle = '#888';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`${prices.length} Candles (No signals generated)`, width / 2, height - 8);
}
return labels;
}
</script>
<div class="signal-chart" bind:this={containerEl}>
{#if signals.length === 0}
{#if signals.length === 0 && klines.length === 0}
<div class="empty-state">
<p>No signals to display</p>
<p>No data to display. Start a simulation to see price movements.</p>
</div>
{:else}
<svg {width} {height} viewBox="0 0 {width} {height}">
<defs>
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(102, 126, 234, 0.3)" />
<stop offset="100%" stop-color="rgba(102, 126, 234, 0)" />
</linearGradient>
</defs>
<g class="grid-lines">
{#each [0, 1, 2, 3] as i}
{@const y = 30 + (i / 3) * (height - 60)}
<line
x1="30" y1={y}
x2={width - 30} y2={y}
stroke="rgba(255,255,255,0.1)"
stroke-dasharray="4,4"
/>
{/each}
</g>
<g class="y-axis">
{#each getYAxisLabels() as label, i}
{@const y = 30 + (i / 3) * (height - 60)}
<text x="25" y={y + 4} class="axis-label" text-anchor="end">${label}</text>
{/each}
</g>
<g class="x-axis">
{#each getXAxisLabels() as label, i}
{@const x = 30 + (i / (getXAxisLabels().length - 1 || 1)) * (width - 60)}
<text x={x} y={height - 8} class="axis-label" text-anchor="middle">{label}</text>
{/each}
</g>
<path
d={signals.map((s, i) => {
const pos = getSignalPosition(s, i, signals.length);
return `${i === 0 ? 'M' : 'L'} ${pos.x} ${pos.y}`;
}).join(' ')}
fill="none"
stroke="#667eea"
stroke-width="2"
/>
{#each signals as signal, i}
{@const pos = getSignalPosition(signal, i, signals.length)}
{@const color = getSignalColor(signal)}
<circle
cx={pos.x}
cy={pos.y}
r="6"
fill={color}
stroke={color}
stroke-width="2"
class="signal-dot"
>
<title>{signal.signal_type.toUpperCase()} - ${signal.price.toFixed(6)} - {new Date(signal.created_at).toLocaleString()}</title>
</circle>
{/each}
</svg>
<div class="legend">
<div class="legend-item">
<span class="legend-dot buy"></span>
<span>Buy</span>
</div>
<div class="legend-item">
<span class="legend-dot sell"></span>
<span>Sell</span>
</div>
<div class="legend-item">
<span class="legend-dot hold"></span>
<span>Hold</span>
</div>
</div>
<canvas
bind:this={canvasEl}
style="width: {width}px; height: {height}px;"
></canvas>
{/if}
</div>
@@ -169,60 +190,11 @@
justify-content: center;
height: 200px;
color: #666;
text-align: center;
padding: 1rem;
}
svg {
canvas {
display: block;
width: 100%;
height: auto;
}
.axis-label {
font-size: 10px;
fill: #666;
}
.signal-dot {
cursor: pointer;
transition: r 0.2s;
}
.signal-dot:hover {
r: 8;
}
.legend {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #888;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.legend-dot.buy {
background: #22c55e;
}
.legend-dot.sell {
background: #ef4444;
}
.legend-dot.hold {
background: #fbbf24;
}
</style>
</style>

View File

@@ -1,9 +1,15 @@
import { writable } from 'svelte/store';
import type { Simulation, Signal } from '$lib/api';
export interface KlineData {
time: number;
close: number;
}
export interface SimulationState {
currentSimulation: Simulation | null;
signals: Signal[];
klines: KlineData[];
isLoading: boolean;
error: string | null;
}
@@ -11,6 +17,7 @@ export interface SimulationState {
const initialState: SimulationState = {
currentSimulation: null,
signals: [],
klines: [],
isLoading: false,
error: null
};
@@ -18,7 +25,11 @@ const initialState: SimulationState = {
export const simulationStore = writable<SimulationState>(initialState);
export function setCurrentSimulation(simulation: Simulation | null) {
simulationStore.update(state => ({ ...state, currentSimulation: simulation }));
simulationStore.update(state => ({
...state,
currentSimulation: simulation,
klines: simulation?.klines || []
}));
}
export function addSignals(newSignals: Signal[]) {

View File

@@ -160,7 +160,7 @@
{#if $simulationStore.signals.length === 0}
<p class="empty-state">No signals yet. Start a simulation to see trading signals.</p>
{:else}
<SignalChart signals={$simulationStore.signals} height={200} />
<SignalChart signals={$simulationStore.signals} klines={$simulationStore.currentSimulation?.klines || []} height={200} />
<div class="signals-list">
{#each $simulationStore.signals as signal}