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
|
engine.run_id = simulation_id
|
||||||
running_simulations[simulation_id] = engine
|
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:
|
try:
|
||||||
# Run engine in background with periodic signal saving
|
# Run simulation (now synchronous - processes klines quickly)
|
||||||
check_interval = config.get("check_interval", 60)
|
results = await engine.run()
|
||||||
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()
|
|
||||||
)
|
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -76,6 +43,11 @@ def run_simulation_sync(
|
|||||||
if simulation:
|
if simulation:
|
||||||
simulation.status = engine.status
|
simulation.status = engine.status
|
||||||
simulation.signals = engine.signals
|
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()
|
db.commit()
|
||||||
|
|
||||||
for signal in engine.signals:
|
for signal in engine.signals:
|
||||||
@@ -163,6 +135,7 @@ async def start_simulation(
|
|||||||
"kline_interval": config.kline_interval,
|
"kline_interval": config.kline_interval,
|
||||||
},
|
},
|
||||||
signals=[],
|
signals=[],
|
||||||
|
klines=[],
|
||||||
)
|
)
|
||||||
db.add(simulation)
|
db.add(simulation)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -237,6 +210,9 @@ def list_simulations(
|
|||||||
if sim.id in running_simulations:
|
if sim.id in running_simulations:
|
||||||
engine = running_simulations[sim.id]
|
engine = running_simulations[sim.id]
|
||||||
sim.signals = engine.get_signals()
|
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
|
return simulations
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ class Simulation(Base):
|
|||||||
status = Column(String, nullable=False)
|
status = Column(String, nullable=False)
|
||||||
config = Column(JSON, nullable=False)
|
config = Column(JSON, nullable=False)
|
||||||
signals = Column(JSON)
|
signals = Column(JSON)
|
||||||
|
klines = Column(JSON) # Price data for chart display
|
||||||
|
|
||||||
bot = relationship("Bot", back_populates="simulations")
|
bot = relationship("Bot", back_populates="simulations")
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ class SimulationResponse(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
config: dict
|
config: dict
|
||||||
signals: Optional[List[dict]]
|
signals: Optional[List[dict]]
|
||||||
|
klines: Optional[List[dict]] = None # Price data for chart
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ class SimulateEngine:
|
|||||||
self.results["errors"] = self.errors
|
self.results["errors"] = self.errors
|
||||||
self.results["signals"] = self.signals
|
self.results["signals"] = self.signals
|
||||||
self.results["candles_processed"] = candles_processed if self.running else 0
|
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["started_at"] = self.started_at
|
||||||
self.results["ended_at"] = datetime.utcnow()
|
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",
|
"name": "frontend",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
"@sveltejs/kit": "^2.57.0",
|
"@sveltejs/kit": "^2.57.0",
|
||||||
@@ -101,6 +104,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
||||||
@@ -569,6 +578,18 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
|
|||||||
@@ -19,5 +19,8 @@
|
|||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.6",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^8.0.7"
|
"vite": "^8.0.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,15 +113,16 @@ export interface Simulation {
|
|||||||
id: string;
|
id: string;
|
||||||
bot_id: string;
|
bot_id: string;
|
||||||
started_at: string;
|
started_at: string;
|
||||||
status: 'running' | 'stopped';
|
status: 'running' | 'stopped' | 'completed';
|
||||||
config: SimulationConfig;
|
config: SimulationConfig;
|
||||||
signals: Signal[] | null;
|
signals: Signal[] | null;
|
||||||
|
klines?: { time: number; close: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimulationConfig {
|
export interface SimulationConfig {
|
||||||
token: string;
|
token: string;
|
||||||
interval_seconds: number;
|
chain?: string;
|
||||||
auto_execute: boolean;
|
kline_interval?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Signal {
|
export interface Signal {
|
||||||
|
|||||||
@@ -1,155 +1,176 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Signal } from '$lib/api';
|
import type { Signal } from '$lib/api';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
signals: Signal[];
|
signals?: Signal[];
|
||||||
|
klines?: { time: number; close: number }[];
|
||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { signals, height = 200 }: Props = $props();
|
let { signals = [], klines = [], height = 200 }: Props = $props();
|
||||||
|
|
||||||
let width = $state(800);
|
let width = $state(800);
|
||||||
let containerEl: HTMLDivElement;
|
let containerEl: HTMLDivElement;
|
||||||
|
let canvasEl: HTMLCanvasElement;
|
||||||
|
|
||||||
$effect(() => {
|
onMount(() => {
|
||||||
if (containerEl) {
|
if (containerEl) {
|
||||||
width = containerEl.clientWidth;
|
width = containerEl.clientWidth;
|
||||||
|
drawChart();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function getSignalPosition(signal: Signal, index: number, total: number): { x: number; y: number } {
|
$effect(() => {
|
||||||
const padding = 30;
|
if (canvasEl && (signals.length > 0 || klines.length > 0)) {
|
||||||
const chartWidth = width - padding * 2;
|
drawChart();
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
function getYAxisLabels(): string[] {
|
function drawChart() {
|
||||||
const range = getPriceRange();
|
if (!canvasEl) return;
|
||||||
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 getXAxisLabels(): string[] {
|
const ctx = canvasEl.getContext('2d');
|
||||||
if (signals.length === 0) return [];
|
if (!ctx) return;
|
||||||
const step = Math.max(1, Math.floor(signals.length / 5));
|
|
||||||
const labels: string[] = [];
|
const dpr = window.devicePixelRatio || 1;
|
||||||
for (let i = 0; i < signals.length; i += step) {
|
canvasEl.width = width * dpr;
|
||||||
labels.push(new Date(signals[i].created_at).toLocaleTimeString());
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="signal-chart" bind:this={containerEl}>
|
<div class="signal-chart" bind:this={containerEl}>
|
||||||
{#if signals.length === 0}
|
{#if signals.length === 0 && klines.length === 0}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p>No signals to display</p>
|
<p>No data to display. Start a simulation to see price movements.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<svg {width} {height} viewBox="0 0 {width} {height}">
|
<canvas
|
||||||
<defs>
|
bind:this={canvasEl}
|
||||||
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
|
style="width: {width}px; height: {height}px;"
|
||||||
<stop offset="0%" stop-color="rgba(102, 126, 234, 0.3)" />
|
></canvas>
|
||||||
<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>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -169,60 +190,11 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
color: #666;
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
canvas {
|
||||||
display: block;
|
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 { writable } from 'svelte/store';
|
||||||
import type { Simulation, Signal } from '$lib/api';
|
import type { Simulation, Signal } from '$lib/api';
|
||||||
|
|
||||||
|
export interface KlineData {
|
||||||
|
time: number;
|
||||||
|
close: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SimulationState {
|
export interface SimulationState {
|
||||||
currentSimulation: Simulation | null;
|
currentSimulation: Simulation | null;
|
||||||
signals: Signal[];
|
signals: Signal[];
|
||||||
|
klines: KlineData[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
@@ -11,6 +17,7 @@ export interface SimulationState {
|
|||||||
const initialState: SimulationState = {
|
const initialState: SimulationState = {
|
||||||
currentSimulation: null,
|
currentSimulation: null,
|
||||||
signals: [],
|
signals: [],
|
||||||
|
klines: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null
|
error: null
|
||||||
};
|
};
|
||||||
@@ -18,7 +25,11 @@ const initialState: SimulationState = {
|
|||||||
export const simulationStore = writable<SimulationState>(initialState);
|
export const simulationStore = writable<SimulationState>(initialState);
|
||||||
|
|
||||||
export function setCurrentSimulation(simulation: Simulation | null) {
|
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[]) {
|
export function addSignals(newSignals: Signal[]) {
|
||||||
|
|||||||
@@ -160,7 +160,7 @@
|
|||||||
{#if $simulationStore.signals.length === 0}
|
{#if $simulationStore.signals.length === 0}
|
||||||
<p class="empty-state">No signals yet. Start a simulation to see trading signals.</p>
|
<p class="empty-state">No signals yet. Start a simulation to see trading signals.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<SignalChart signals={$simulationStore.signals} height={200} />
|
<SignalChart signals={$simulationStore.signals} klines={$simulationStore.currentSimulation?.klines || []} height={200} />
|
||||||
|
|
||||||
<div class="signals-list">
|
<div class="signals-list">
|
||||||
{#each $simulationStore.signals as signal}
|
{#each $simulationStore.signals as signal}
|
||||||
|
|||||||
Reference in New Issue
Block a user