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

@@ -31,42 +31,9 @@ def run_simulation_sync(
engine.run_id = simulation_id
running_simulations[simulation_id] = engine
def save_signals_to_db():
"""Save current signals to database."""
db = SessionLocal()
try:
simulation = (
db.query(Simulation).filter(Simulation.id == simulation_id).first()
)
if simulation:
simulation.signals = engine.signals
db.commit()
finally:
db.close()
try:
# Run engine in background with periodic signal saving
check_interval = config.get("check_interval", 60)
save_interval = min(check_interval, 30) # Save at least every 30 seconds
async def run_with_periodic_save():
last_save_time = time.time()
while engine.running and engine.status == "running":
await asyncio.sleep(1) # Check every second
current_time = time.time()
if current_time - last_save_time >= save_interval:
save_signals_to_db()
last_save_time = current_time
# Final save when done
save_signals_to_db()
# Run both the engine and periodic save concurrently
await asyncio.gather(
engine.run(),
run_with_periodic_save()
)
# Run simulation (now synchronous - processes klines quickly)
results = await engine.run()
db = SessionLocal()
try:
@@ -76,6 +43,11 @@ def run_simulation_sync(
if simulation:
simulation.status = engine.status
simulation.signals = engine.signals
# Save klines for chart display (only time and close price)
simulation.klines = [
{"time": k.get("time"), "close": k.get("close")}
for k in engine.klines
]
db.commit()
for signal in engine.signals:
@@ -163,6 +135,7 @@ async def start_simulation(
"kline_interval": config.kline_interval,
},
signals=[],
klines=[],
)
db.add(simulation)
db.commit()
@@ -237,6 +210,9 @@ def list_simulations(
if sim.id in running_simulations:
engine = running_simulations[sim.id]
sim.signals = engine.get_signals()
# Include klines from running engine for chart display
if hasattr(engine, 'klines'):
sim.klines = [{"time": k.get("time"), "close": k.get("close")} for k in engine.klines]
return simulations

View File

@@ -93,6 +93,7 @@ class Simulation(Base):
status = Column(String, nullable=False)
config = Column(JSON, nullable=False)
signals = Column(JSON)
klines = Column(JSON) # Price data for chart display
bot = relationship("Bot", back_populates="simulations")

View File

@@ -117,6 +117,7 @@ class SimulationResponse(BaseModel):
status: str
config: dict
signals: Optional[List[dict]]
klines: Optional[List[dict]] = None # Price data for chart
class Config:
from_attributes = True

View File

@@ -129,6 +129,7 @@ class SimulateEngine:
self.results["errors"] = self.errors
self.results["signals"] = self.signals
self.results["candles_processed"] = candles_processed if self.running else 0
self.results["klines"] = self.klines # Include klines for chart display
self.results["started_at"] = self.started_at
self.results["ended_at"] = datetime.utcnow()

View File

@@ -0,0 +1,298 @@
import pytest
import asyncio
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
import sys
sys.path.insert(0, 'src/backend')
from app.services.simulate.engine import SimulateEngine
class MockAveClient:
"""Mock AVE client for testing."""
def __init__(self, klines_data=None):
self.klines_data = klines_data or []
async def get_klines(self, token_id, interval="1m", limit=100, start_time=None, end_time=None):
return self.klines_data
def create_engine(config_override=None, klines_data=None):
"""Create a test engine with mock client."""
config = {
"bot_id": "test-bot",
"token": "0x1234567890123456789012345678901234567890",
"chain": "bsc",
"kline_interval": "1m",
"max_candles": 100,
"auto_execute": False,
"strategy_config": {
"conditions": [
{"type": "price_drop", "threshold": 5, "token": "TEST", "token_address": "0x1234"}
],
"actions": [
{"type": "buy", "amount_percent": 10}
],
"risk_management": {
"stop_loss_percent": 5,
"take_profit_percent": 10
}
},
"ave_api_key": "test",
"ave_api_plan": "free",
}
if config_override:
config.update(config_override)
engine = SimulateEngine(config)
engine.ave_client = MockAveClient(klines_data)
return engine
class TestSimulateEngine:
"""Unit tests for SimulateEngine."""
# ==================== Kline Fetching Tests ====================
@pytest.mark.asyncio
async def test_fetches_klines_on_start(self):
"""Engine should fetch klines when run is called."""
klines = [
{"time": 1000, "open": 100, "high": 105, "low": 98, "close": 102, "volume": 1000},
{"time": 2000, "open": 102, "high": 107, "low": 100, "close": 104, "volume": 1100},
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
assert engine.status == "completed"
assert results["candles_processed"] == 2
@pytest.mark.asyncio
async def test_handles_no_klines_data(self):
"""Engine should handle empty klines gracefully."""
engine = create_engine(klines_data=[])
engine.running = True
results = await engine.run()
assert engine.status == "failed"
assert "error" in results
assert "No kline data" in results["error"]
# ==================== Price Drop Condition Tests ====================
@pytest.mark.asyncio
async def test_price_drop_condition_triggers_buy(self):
"""Price drop >= threshold should trigger BUY signal."""
# Price drops from 100 to 90 (10% drop) - should trigger 5% threshold
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # 10% drop
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
assert results["total_signals"] >= 1
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
assert len(buy_signals) >= 1
assert buy_signals[0]["price"] == 90.0
@pytest.mark.asyncio
async def test_price_drop_below_threshold_no_signal(self):
"""Price drop < threshold should NOT trigger signal."""
# Price drops from 100 to 98 (2% drop) - below 5% threshold
klines = [
{"time": 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 97, "close": 98, "volume": 1000}, # 2% drop
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
assert results["total_signals"] == 0
# ==================== Risk Management Tests ====================
@pytest.mark.asyncio
async def test_stop_loss_triggers_after_buy(self):
"""Stop loss should trigger SELL after price drops below threshold."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered @ 90
{"time": 3000, "open": 90, "high": 91, "low": 84, "close": 85, "volume": 1300}, # Stop loss @ 85.5 (90 * 0.95)
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
assert len(buy_signals) >= 1, "Should have at least one BUY signal"
assert len(sell_signals) >= 1, "Stop loss should trigger SELL"
assert "stop_loss" in sell_signals[0]["reasoning"]
@pytest.mark.asyncio
async def test_take_profit_triggers_after_buy(self):
"""Take profit should trigger SELL after price rises above threshold."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered @ 90
{"time": 3000, "open": 90, "high": 101, "low": 89, "close": 100, "volume": 1300}, # TP @ 99 (90 * 1.10)
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
assert len(buy_signals) >= 1, "Should have at least one BUY signal"
assert len(sell_signals) >= 1, "Take profit should trigger SELL"
assert "take_profit" in sell_signals[0]["reasoning"]
# ==================== Multiple Conditions Tests ====================
@pytest.mark.asyncio
async def test_no_buy_if_already_in_position(self):
"""Should not trigger another BUY if already holding position."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered
{"time": 3000, "open": 90, "high": 91, "low": 85, "close": 86, "volume": 1300}, # Another drop but already in position
{"time": 4000, "open": 86, "high": 87, "low": 81, "close": 82, "volume": 1400}, # Another drop
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
# Should only have 1 buy, not multiple
assert len(buy_signals) == 1, "Should only have one BUY signal"
@pytest.mark.asyncio
async def test_can_buy_again_after_sell(self):
"""Should be able to BUY again after position is closed by risk management."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
# First trade
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY @ 90
{"time": 3000, "open": 90, "high": 91, "low": 84, "close": 85, "volume": 1300}, # STOP LOSS @ 85.5
# Second trade
{"time": 4000, "open": 85, "high": 86, "low": 79, "close": 80, "volume": 1400}, # BUY @ 80 (after position closed)
{"time": 5000, "open": 80, "high": 89, "low": 79, "close": 88, "volume": 1500}, # TP @ 88
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
assert len(buy_signals) == 2, "Should have two BUY signals"
assert len(sell_signals) == 2, "Should have two SELL signals"
# ==================== Edge Cases ====================
@pytest.mark.asyncio
async def test_handles_zero_price(self):
"""Should skip processing for candles with zero price but still count them."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 0, "high": 0, "low": 0, "close": 0, "volume": 0}, # Skipped in processing
{"time": 3000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # This should work
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
# All 3 candles counted, but only 2 valid for condition checking
assert results["candles_processed"] == 3
# Only 1 signal (the valid candle that dropped 10%)
assert results["total_signals"] == 1
@pytest.mark.asyncio
async def test_max_candles_limit(self):
"""Should respect max_candles limit."""
klines = [
{"time": i * 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}
for i in range(1, 201) # 200 candles
]
engine = create_engine(klines_data=klines, config_override={"max_candles": 50})
engine.running = True
results = await engine.run()
assert results["candles_processed"] == 50
@pytest.mark.asyncio
async def test_stop_interrupts_processing(self):
"""Should stop processing when stop() is called."""
klines = [
{"time": i * 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}
for i in range(1, 101)
]
engine = create_engine(klines_data=klines)
engine.running = True
engine.run_id = "test"
# Stop after a few candles
async def stop_after_delay():
await asyncio.sleep(0.1)
engine.stop()
await asyncio.gather(engine.run(), stop_after_delay())
assert engine.status == "stopped"
# Should have processed some candles before stopping
assert engine.last_processed_time is not None
# ==================== Price Movement Display Tests ====================
@pytest.mark.asyncio
async def test_records_all_processed_prices(self):
"""Should track last processed time for display purposes."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 99, "close": 101, "volume": 1100},
{"time": 3000, "open": 101, "high": 103, "low": 100, "close": 102, "volume": 1200},
]
engine = create_engine(klines_data=klines)
engine.running = True
await engine.run()
# Should have tracked the last candle's time
assert engine.last_processed_time == 3000
@pytest.mark.asyncio
async def test_tracks_price_changes(self):
"""Should track price changes for potential chart display."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 105, "low": 99, "close": 104, "volume": 1100},
]
engine = create_engine(klines_data=klines)
engine.running = True
await engine.run()
# Last close should be the last candle's close
assert engine.last_close == 104.0
if __name__ == "__main__":
pytest.main([__file__, "-v"])

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}