diff --git a/.gitignore b/.gitignore index d22f786..4e0ec16 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ data/ .cert .cert/ CLAUDE.md +brag-output/ # --- DATABASE --- *.sqlite diff --git a/README.md b/README.md index 3d31c36..8a47e1d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,18 @@ ApexAlgo is a full-stack algorithmic trading platform for building, backtesting, --- +## Demo + +One command to your first bot — 24 s, with sound. + +https://github.com/user-attachments/assets/25d03926-1e88-4749-88d4-b97b14ff9c2b + + + + + +Player not showing? [Download the MP4](docs/screenshots/demo.mp4?raw=true). + ## Screenshots **Chart Engine** — TradingView-grade candles with per-bot overlays: indicator lines, historical/live trade markers and the engine's raw buy/sell "thoughts", all toggled per algorithm. diff --git a/backend/main.py b/backend/main.py index 1d9cf6c..bedcb02 100644 --- a/backend/main.py +++ b/backend/main.py @@ -63,7 +63,7 @@ async def lifespan(app: FastAPI): enable_docs = os.getenv("ENABLE_DOCS", "0") == "1" app = FastAPI( title="ApexAlgo Engine API", - version="2.1.0", + version="2.1.1", swagger_ui_init_oauth={"clientId": "test"}, lifespan=lifespan, docs_url="/docs" if enable_docs else None, diff --git a/backend/routers/bots.py b/backend/routers/bots.py index 5ec92c7..0be6878 100644 --- a/backend/routers/bots.py +++ b/backend/routers/bots.py @@ -35,6 +35,22 @@ def _resolve_exchange(settings: dict, db: Session) -> str: return key.exchange return settings.get("data_exchange", "okx") + +def _candle_exchange(settings: dict, key_exchange_by_name: dict) -> str: + """The exchange this bot's candles (and therefore its signals) come from — + the same rule as engine startup: an API key that actually routes orders + wins, otherwise the configured data exchange.""" + settings = settings or {} + if settings.get("api_execution") and settings.get("api_key_name"): + key_exchange = key_exchange_by_name.get(settings["api_key_name"]) + if key_exchange: + return key_exchange + return settings.get("data_exchange") or "okx" + + +def _key_exchanges(db: Session) -> dict: + return {name: ex for name, ex in db.query(ExchangeKey.name, ExchangeKey.exchange).all()} + logger = logging.getLogger("apexalgo.bots") router = APIRouter( @@ -56,13 +72,22 @@ class BotResponse(BotBase): id: int is_active: bool created_at: datetime + # Effective candle/signal exchange (key exchange when orders are routed, + # else data_exchange) — the chart uses it to scope overlays to a dataset + exchange: Optional[str] = None class Config: from_attributes = True @router.get("/", response_model=List[BotResponse]) def get_all_bots(db: Session = Depends(get_db)): - return db.query(BotConfig).all() + key_exchanges = _key_exchanges(db) + out = [] + for b in db.query(BotConfig).all(): + item = BotResponse.model_validate(b) + item.exchange = _candle_exchange(b.settings, key_exchanges) + out.append(item) + return out @router.get("/by-id/{bot_id}", response_model=BotResponse) @@ -90,6 +115,7 @@ def get_bots_summary(db: Session = Depends(get_db)): """Lightweight bot list for polling — excludes full settings/node graph.""" bots = db.query(BotConfig).all() sandbox_by_key = {k.name: bool(k.is_sandbox) for k in db.query(ExchangeKey.name, ExchangeKey.is_sandbox).all()} + key_exchanges = _key_exchanges(db) return [ { "id": b.id, @@ -98,6 +124,9 @@ def get_bots_summary(db: Session = Depends(get_db)): "is_sandbox": b.is_sandbox, "created_at": b.created_at.isoformat() if b.created_at else None, "execution_mode": _execution_mode(b.settings, sandbox_by_key), + # Where this bot's candles come from (key exchange when routing + # orders, else data_exchange) — chart-open and Data Vault key on it + "exchange": _candle_exchange(b.settings, key_exchanges), "settings": { "timeframe": b.settings.get("timeframe") if b.settings else None, "symbols": b.settings.get("symbols", []) if b.settings else [], @@ -129,12 +158,18 @@ def get_bots_summary(db: Session = Depends(get_db)): @router.get("/signals") -def get_bot_signals(symbol: str, timeframe: str, limit: int = Query(default=5000, le=200000), since_id: int = Query(default=0, ge=0), db: Session = Depends(get_db)): +def get_bot_signals(symbol: str, timeframe: str, exchange: str = Query(default="okx"), limit: int = Query(default=5000, le=200000), since_id: int = Query(default=0, ge=0), db: Session = Depends(get_db)): bot_rows = db.query(BotConfig.name, BotConfig.settings).all() + key_exchanges = _key_exchanges(db) + exchange = exchange.lower() + # Same pair + interval on another exchange is a different dataset: only + # bots that trade this exact (exchange, symbol, timeframe) belong on the chart valid_bot_names = [ name for name, settings in bot_rows - if settings and settings.get('timeframe') == timeframe and (symbol in settings.get('symbols', []) or symbol == settings.get('symbol')) + if settings and settings.get('timeframe') == timeframe + and (symbol in settings.get('symbols', []) or symbol == settings.get('symbol')) + and _candle_exchange(settings, key_exchanges).lower() == exchange ] if not valid_bot_names: diff --git a/docs/screenshots/demo.mp4 b/docs/screenshots/demo.mp4 new file mode 100644 index 0000000..6588058 Binary files /dev/null and b/docs/screenshots/demo.mp4 differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3defcb2..ae60cbe 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "2.1.0", + "version": "2.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@fontsource-variable/inter": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 95249eb..c7c9032 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.1.0", + "version": "2.1.1", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b42def6..35b617d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -91,7 +91,9 @@ export default function App() { localStorage.setItem('apex_activeView', activeView); }, [activeView]); + const openChartsRef = useRef(openCharts); useEffect(() => { + openChartsRef.current = openCharts; localStorage.setItem('apex_openCharts', JSON.stringify(openCharts)); }, [openCharts]); @@ -181,6 +183,28 @@ export default function App() { }; }, [refetchBots, hasApiKey]); + // Chart tabs live in localStorage, so they outlive their candles when data + // is deleted. Drop any tab whose (exchange, symbol, timeframe) no longer + // exists — on login and whenever the Data Vault deletes something. + useEffect(() => { + if (!hasApiKey) return; + let cancelled = false; + const pruneCharts = async () => { + let rows; + try { rows = (await apiClient.get('/api/data/summary')).data; } catch { return; } + if (cancelled || !Array.isArray(rows)) return; + const have = new Set(rows.map(r => `${(r.exchange || 'okx').toLowerCase()}_${r.symbol}_${r.timeframe}`)); + const prev = openChartsRef.current; + const kept = prev.filter(c => have.has(c.id)); + if (kept.length === prev.length) return; + setOpenCharts(kept); + setActiveView(v => (prev.some(c => c.id === v) && !kept.some(c => c.id === v)) ? 'home' : v); + }; + pruneCharts(); + window.addEventListener('data-changed', pruneCharts); + return () => { cancelled = true; window.removeEventListener('data-changed', pruneCharts); }; + }, [hasApiKey]); + const handleOpenChart = (dataset) => { // Exchange is part of the identity: the same pair can be open // for two exchanges side by side @@ -199,7 +223,7 @@ export default function App() { : (bot.settings?.symbol ? [bot.settings.symbol] : []); const timeframe = bot.settings?.timeframe || "15m"; - const exchange = (bot.settings?.data_exchange || 'okx').toLowerCase(); + const exchange = (bot.exchange || bot.settings?.data_exchange || 'okx').toLowerCase(); let updatedCharts = [...openCharts]; let lastOpenedChartId = ""; @@ -284,16 +308,27 @@ export default function App() { settings: 'Exchange Configuration', }; + const isChartView = openCharts.some(c => c.id === activeView); + // Home keeps its hero on desktop; with the sidebar collapsed (mobile) every + // non-chart view gets the same slim top bar so the menu button has a home. + const headerTitle = HEADER_TITLES[activeView] || (!sidebarOpen && !isChartView ? 'Dashboard' : null); + const menuButton = ( + + ); + return (