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 (
- + {/* Charts have their own toolbar, so the menu button floats there. */} + {!sidebarOpen && isChartView && ( +
{menuButton}
+ )} {sidebarOpen && (
setSidebarOpen(false)}>
@@ -314,14 +349,13 @@ export default function App() {
- {HEADER_TITLES[activeView] && ( -
+ {headerTitle && ( +
-
-

- {HEADER_TITLES[activeView]} -

-
+ {!sidebarOpen && menuButton} +

+ {headerTitle} +

)} diff --git a/frontend/src/components/BotManagerUI.jsx b/frontend/src/components/BotManagerUI.jsx index f0a3a31..4d8045a 100644 --- a/frontend/src/components/BotManagerUI.jsx +++ b/frontend/src/components/BotManagerUI.jsx @@ -662,7 +662,7 @@ export default function BotManagerUI({ bots = [], refetchBots, backendOk = true : 'Manage, configure, and deploy automated strategies'} accentColor="neutral" action={ -
+
{bots.length > 1 && (
diff --git a/frontend/src/components/DataManager.jsx b/frontend/src/components/DataManager.jsx index 016c236..4385b60 100644 --- a/frontend/src/components/DataManager.jsx +++ b/frontend/src/components/DataManager.jsx @@ -108,7 +108,7 @@ export default function DataManager({ openChart }) { const keys = new Set(); botsRes.data.filter(b => b.is_active).forEach(b => { const s = b.settings || {}; - const ex = (s.data_exchange || 'okx').toLowerCase(); + const ex = (b.exchange || s.data_exchange || 'okx').toLowerCase(); const tf = s.timeframe; const syms = s.symbols?.length ? s.symbols : (s.symbol ? [s.symbol] : []); syms.forEach(sym => keys.add(`${ex}|${sym}|${tf}`)); @@ -196,10 +196,10 @@ export default function DataManager({ openChart }) { setSyncingSymbol(null); }; - const executeDelete = async (delSymbol, delTimeframe, beforeDateStr) => { + const executeDelete = async (delSymbol, delTimeframe, beforeDateStr, delExchange) => { setLoading(true); try { - let endpoint = `/api/data?symbol=${encodeURIComponent(delSymbol)}&timeframe=${delTimeframe}`; + let endpoint = `/api/data?symbol=${encodeURIComponent(delSymbol)}&timeframe=${delTimeframe}&exchange=${encodeURIComponent(delExchange || 'okx')}`; if (beforeDateStr && beforeDateStr.trim() !== "") { const isoDate = new Date(beforeDateStr).toISOString(); endpoint += `&before_date=${isoDate}`; @@ -209,6 +209,7 @@ export default function DataManager({ openChart }) { setPruneModalConfig(null); toast.success(res.data.message); fetchSummary(); + window.dispatchEvent(new CustomEvent('data-changed')); } catch (err) { toast.error(humanizeApiError(err, 'Failed to delete candle data.')); setPruneModalConfig(null); @@ -222,7 +223,7 @@ export default function DataManager({ openChart }) { return; } setPruneDate(''); - setPruneModalConfig({ symbol: row.symbol, timeframe: row.timeframe }); + setPruneModalConfig({ symbol: row.symbol, timeframe: row.timeframe, exchange: row.exchange || 'okx' }); }; const uniqueSymbols = useMemo(() => [...new Set(summary.map(r => r.symbol))], [summary]); @@ -263,9 +264,10 @@ export default function DataManager({ openChart }) { setLoading(true); try { await Promise.all(deletable.map(row => - apiClient.delete(`/api/data?symbol=${encodeURIComponent(row.symbol)}&timeframe=${row.timeframe}`) + apiClient.delete(`/api/data?symbol=${encodeURIComponent(row.symbol)}&timeframe=${row.timeframe}&exchange=${encodeURIComponent(row.exchange || 'okx')}`) )); fetchSummary(); + window.dispatchEvent(new CustomEvent('data-changed')); toast.success('Successfully deleted all data matching your filters.'); } catch { toast.error('Failed to delete some data.'); @@ -372,7 +374,7 @@ export default function DataManager({ openChart }) { variant="danger" size="sm" loading={loading} - onClick={() => executeDelete(pruneModalConfig.symbol, pruneModalConfig.timeframe, '')} + onClick={() => executeDelete(pruneModalConfig.symbol, pruneModalConfig.timeframe, '', pruneModalConfig.exchange)} > Delete All @@ -382,7 +384,7 @@ export default function DataManager({ openChart }) { loading={loading} disabled={!pruneDate} className="!bg-danger !text-danger-ink hover:!bg-danger-hover" - onClick={() => executeDelete(pruneModalConfig.symbol, pruneModalConfig.timeframe, pruneDate)} + onClick={() => executeDelete(pruneModalConfig.symbol, pruneModalConfig.timeframe, pruneDate, pruneModalConfig.exchange)} > Prune Date diff --git a/frontend/src/components/Home.jsx b/frontend/src/components/Home.jsx index 0db9aa8..dd77b9f 100644 --- a/frontend/src/components/Home.jsx +++ b/frontend/src/components/Home.jsx @@ -163,7 +163,7 @@ export default function Home({ setActiveView, bots = [], backendOk = true, refet return (
-
+
{/* Hero */}
diff --git a/frontend/src/components/TradeManager.jsx b/frontend/src/components/TradeManager.jsx index 2e73fed..4442814 100644 --- a/frontend/src/components/TradeManager.jsx +++ b/frontend/src/components/TradeManager.jsx @@ -72,6 +72,16 @@ const entryTimeOf = (p, exitTs, entryTsByPos) => { const pnlColor = (v) => (v >= 0 ? 'text-success' : 'text-danger'); const pnlSign = (v) => (v >= 0 ? '+' : ''); +// Mode filter values → the label the collapsed (phone) filter summary shows. +const MODE_LABELS = { + real: 'Paper + Live', + live: 'Live', + paper: 'Paper', + forward_test: 'Forward test', + backtest: 'Backtest', + all: 'All modes', +}; + // ─── Equity Curve SVG ──────────────────────────────────────────────────────── const EquityCurve = ({ data }) => { @@ -352,6 +362,8 @@ export default function TradeManager({ setError, bots = [], request = null }) { const [closingId, setClosingId] = useState(null); const [filterBot, setFilterBot] = useState(request?.bot || 'all'); + // Phones start with the filter panel folded to a one-line summary; desktop always shows it. + const [filtersOpen, setFiltersOpen] = useState(() => typeof window === 'undefined' || window.innerWidth >= 768); const [filterSymbol, setFilterSymbol] = useState('all'); const [filterExchange, setFilterExchange] = useState('all'); // null = auto (resolved from the loaded positions, see defaultModeFor) @@ -1105,7 +1117,27 @@ export default function TradeManager({ setError, bots = [], request = null }) { {/* ── FILTER BAR ─────────────────────────────────────────────────── */} -
+
+ {/* Phone: one summary line that unfolds into the full panel (desktop shows it always). */} +
+ + +
+