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:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
298
src/backend/tests/test_simulate_engine.py
Normal file
298
src/backend/tests/test_simulate_engine.py
Normal 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"])
|
||||
21
src/frontend/package-lock.json
generated
21
src/frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -19,5 +19,8 @@
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user