"""Paper trading API endpoints — full session management.""" from __future__ import annotations import datetime as dt import os from pathlib import Path from typing import Any from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel from apps.web.dependencies import get_project_root from apps.web import paper_trading_service as svc router = APIRouter(prefix="/paper", tags=["paper_trading"]) # ── Helpers ─────────────────────────────────────────────────────────────────── def _get_db_path() -> str: db = os.environ.get("PAPER_TRADER_DB", "paper_trading.db") p = Path(db) if not p.is_absolute(): p = get_project_root() / db return str(p) def _get_state_manager(): from apps.paper_trader.state import StateManager return StateManager(_get_db_path()) def _get_broker(): from apps.paper_trader.alpaca_broker import AlpacaBroker return AlpacaBroker.from_env() def _session_summary(session, state) -> dict[str, Any]: """Build a JSON-serializable summary of a session.""" snapshots = state.list_snapshots(session.session_id) session_st = state.get_session_state(session.session_id) latest = snapshots[-1] if snapshots else None initial = session.initial_equity trades = state.list_trades(session.session_id) # Equity from trades: initial + realized PnL + unrealized (live from broker) realized_pnl = sum( float(t["net_pnl"]) for t in trades if t.get("net_pnl") is not None ) unrealized_pnl = 0.0 try: broker = _get_broker() alpaca_positions = broker.list_positions() price_map = {p.symbol: float(p.current_price) for p in alpaca_positions if p.current_price} strategy_symbols = { ss.symbol for ss in state.get_open_strategy_states(session.session_id) } # Aggregate per-session open shares from trades sym_shares: dict[str, int] = {} sym_cost: dict[str, float] = {} for t in trades: if t.get("exit_date") is not None or t.get("net_pnl") is not None: continue sym = t["symbol"] if sym not in strategy_symbols: continue shares = t.get("shares") or 0 entry = float(t.get("entry_price") or 0.0) sym_shares[sym] = sym_shares.get(sym, 0) + shares sym_cost[sym] = sym_cost.get(sym, 0.0) + shares * entry for sym, shares in sym_shares.items(): if shares <= 0: continue avg_e = sym_cost[sym] / shares cur = price_map.get(sym, avg_e) unrealized_pnl += shares * (cur - avg_e) # Parking unrealized parking_st = state.get_parking_state(session.session_id) if parking_st: psym = parking_st["symbol"] pqty = float(parking_st.get("qty") or 0) pavg = float(parking_st.get("avg_price") or 0) pcur = price_map.get(psym, pavg) unrealized_pnl += pqty * (pcur - pavg) except Exception: pass current = initial + realized_pnl + unrealized_pnl peak = max(state.get_peak_equity(session.session_id, initial), current) drawdown_pct = max(0.0, (peak - current) / peak * 100) if peak > 0 else 0.0 total_pnl = current - initial total_pnl_pct = total_pnl / initial * 100 if initial else 0.0 return { "session_id": session.session_id, "session_name": session.session_name, "config_path": session.config_path, "initial_equity": initial, "created_at": session.created_at, "status": session.status, "parking_preset": session.parking_preset, "idle_alpha_preset": session.idle_alpha_preset, "form4_sleeve_preset": session.form4_sleeve_preset, "ownership_sleeve_preset": session.ownership_sleeve_preset, "risk_off_alpha_sleeve_preset": getattr(session, "risk_off_alpha_sleeve_preset", None), "current_equity": current, "peak_equity": peak, "total_pnl": total_pnl, "total_pnl_pct": total_pnl_pct, "drawdown_pct": drawdown_pct, "trade_count": len(trades), "latest_date": latest["date"] if latest else None, "kill_switch": bool(session_st.kill_switch_triggered), "cooldown_remaining": session_st.cooldown_remaining, "consecutive_losses": session_st.consecutive_losses, "daily_new_risk_used": session_st.daily_new_risk_used, "last_processed_date": session_st.last_processed_date, } def _auto_schedule_info() -> list[dict[str, Any]]: """Return schedule with countdown info (server-side computation).""" from zoneinfo import ZoneInfo TZ_ET = ZoneInfo("America/New_York") SCHEDULE = [ {"name": "pipeline_pre", "et_hour": 7, "et_min": 0, "label": "Pre-market pipeline"}, {"name": "run_open", "et_hour": 9, "et_min": 35, "label": "run-open (장 시작 직후)"}, {"name": "run_close", "et_hour": 15, "et_min": 45, "label": "run-close (장 마감 직전)"}, {"name": "pipeline_post", "et_hour": 16, "et_min": 30, "label": "Post-close pipeline"}, ] now_et = dt.datetime.now(tz=TZ_ET) today = now_et.date() result = [] for ev in SCHEDULE: et_dt = dt.datetime(today.year, today.month, today.day, ev["et_hour"], ev["et_min"], tzinfo=TZ_ET) wait_secs = (et_dt - now_et).total_seconds() result.append({ "name": ev["name"], "label": ev["label"], "et_time": et_dt.strftime("%I:%M %p ET"), "wait_secs": max(0.0, wait_secs), }) return result # ── Sessions ────────────────────────────────────────────────────────────────── @router.get("/sessions") def list_sessions() -> dict[str, Any]: state = _get_state_manager() sessions = state.list_sessions() return {"sessions": [_session_summary(s, state) for s in sessions]} class CreateSessionRequest(BaseModel): name: str config: str # experiment name, numeric ID, or config path capital: float = 10000.0 parking: str | None = None # optional parking preset name idle_alpha: str | None = None # optional idle alpha sleeve preset name form4_sleeve: str | None = None # optional Form 4 sleeve preset name ownership_sleeve: str | None = None # optional 13D/13G ownership sleeve preset name risk_off_sleeve: str | None = None # optional risk-off alpha sleeve preset name @router.post("/sessions") def create_session(req: CreateSessionRequest) -> dict[str, Any]: from apps.paper_trader.cli import _resolve_config_path config_path = _resolve_config_path(req.config) abs_path = Path(config_path) if not abs_path.is_absolute(): abs_path = get_project_root() / config_path if not abs_path.exists(): raise HTTPException(status_code=400, detail=f"Config not found: {config_path}") config_path = str(config_path) # keep relative for storage # Validate parking preset if provided if req.parking: try: from libs.backtest.domain import PARKING_PRESETS if req.parking not in PARKING_PRESETS: raise HTTPException(status_code=400, detail=f"Unknown parking preset: {req.parking}") except ImportError: pass if req.idle_alpha: try: from libs.backtest.domain import IDLE_ALPHA_SLEEVE_PRESETS if req.idle_alpha not in IDLE_ALPHA_SLEEVE_PRESETS: raise HTTPException(status_code=400, detail=f"Unknown idle alpha preset: {req.idle_alpha}") except ImportError: pass if req.form4_sleeve: try: from libs.backtest.domain import FORM4_CAPTURE_SLEEVE_PRESETS if req.form4_sleeve not in FORM4_CAPTURE_SLEEVE_PRESETS: raise HTTPException(status_code=400, detail=f"Unknown Form 4 sleeve preset: {req.form4_sleeve}") except ImportError: pass if req.ownership_sleeve: try: from libs.backtest.domain import OWNERSHIP_CAPTURE_SLEEVE_PRESETS if req.ownership_sleeve not in OWNERSHIP_CAPTURE_SLEEVE_PRESETS: raise HTTPException(status_code=400, detail=f"Unknown ownership sleeve preset: {req.ownership_sleeve}") except ImportError: pass if req.risk_off_sleeve: try: from libs.backtest.domain import RISK_OFF_ALPHA_SLEEVE_PRESETS if req.risk_off_sleeve not in RISK_OFF_ALPHA_SLEEVE_PRESETS: raise HTTPException(status_code=400, detail=f"Unknown risk-off sleeve preset: {req.risk_off_sleeve}") except ImportError: pass state = _get_state_manager() if state.get_session(req.name) is not None: raise HTTPException(status_code=409, detail=f"Session '{req.name}' already exists") session_id = state.create_session( session_name=req.name, config_path=config_path, initial_equity=req.capital, parking_preset=req.parking or None, idle_alpha_preset=req.idle_alpha or None, form4_sleeve_preset=req.form4_sleeve or None, ownership_sleeve_preset=req.ownership_sleeve or None, risk_off_alpha_sleeve_preset=req.risk_off_sleeve or None, ) return { "session_id": session_id, "name": req.name, "capital": req.capital, "config": config_path, "parking": req.parking, "idle_alpha": req.idle_alpha, "form4_sleeve": req.form4_sleeve, "ownership_sleeve": req.ownership_sleeve, "risk_off_sleeve": req.risk_off_sleeve, } @router.get("/sessions/{session_id}") def get_session(session_id: str) -> dict[str, Any]: state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail=f"Session not found: {session_id}") return _session_summary(session, state) @router.post("/sessions/{session_id}/pause") def pause_session(session_id: str) -> dict[str, Any]: state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") if session.status != "active": raise HTTPException(status_code=400, detail=f"Session is already {session.status}") state.set_session_status(session.session_id, "paused") return {"session_id": session_id, "status": "paused"} @router.post("/sessions/{session_id}/resume") def resume_session(session_id: str) -> dict[str, Any]: state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") if session.status != "paused": raise HTTPException(status_code=400, detail=f"Session is {session.status}, not paused") state.set_session_status(session.session_id, "active") return {"session_id": session_id, "status": "active"} @router.delete("/sessions/{session_id}") def close_session(session_id: str) -> dict[str, Any]: """Liquidate this session's Alpaca positions and delete all session data.""" state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") # Collect (symbol, qty) for this session only — use trade records for strategy # positions so orphaned shares in the broker are not accidentally sold. positions_to_close: list[tuple[str, int | None]] = [] open_trades_by_sym = { t["symbol"]: t for t in state.list_trades(session.session_id) if t.get("exit_date") is None } for ss in state.get_open_strategy_states(session.session_id): ot = open_trades_by_sym.get(ss.symbol) qty = int(ot["shares"]) if ot and ot.get("shares") else None positions_to_close.append((ss.symbol, qty)) parking = state.get_parking_state(session.session_id) if parking: # Use DB qty to avoid closing other sessions' shared parking positions. # (Alpaca is account-level: qty=None would close ALL shares across sessions.) parking_qty = int(parking.get("qty") or 0) or None positions_to_close.append((parking["symbol"].upper(), parking_qty)) import time orders_closed = 0 broker_error: str | None = None fill_prices: dict[str, float] = {} try: broker = _get_broker() pending_orders: list[tuple[str, str, int | None]] = [] # (order_id, sym, qty) for sym, qty in positions_to_close: try: order = broker.close_position(sym, qty=qty) orders_closed += 1 pending_orders.append((order.id, sym, qty)) except Exception as exc: broker_error = (broker_error + "; " if broker_error else "") + f"{sym}: {exc}" # Poll for fills so trade records capture actual exit price MAX_ATTEMPTS, POLL_INTERVAL = 6, 5 for attempt in range(MAX_ATTEMPTS): if not pending_orders: break time.sleep(POLL_INTERVAL) still_pending = [] for order_id, sym, qty in pending_orders: try: o = broker.get_order(order_id) if o.status == "filled" and o.filled_avg_price: fill_prices[sym] = float(o.filled_avg_price) elif o.status in ("canceled", "expired", "rejected", "cancelled"): fill_prices[sym] = 0.0 else: still_pending.append((order_id, sym, qty)) except Exception: still_pending.append((order_id, sym, qty)) pending_orders = still_pending except Exception as exc: broker_error = str(exc) today_str = dt.date.today().isoformat() for ss in state.get_open_strategy_states(session.session_id): sym = ss.symbol exit_price = fill_prices.get(sym, 0.0) ot = open_trades_by_sym.get(sym) if ot: shares = int(ot.get("shares") or 0) entry_price = ot.get("entry_price") or 0.0 net_pnl = (exit_price - entry_price) * shares if exit_price else 0.0 state.close_trade( session_id=session.session_id, symbol=sym, engine_id=ot.get("engine_id"), capital_bucket_id=ot.get("capital_bucket_id"), entry_date=ot.get("entry_date"), exit_date=today_str, entry_price=entry_price, exit_price=exit_price, exit_reason="SESSION_CLOSED", shares=shares, net_pnl=net_pnl, r_multiple=0.0, holding_days=0, ) state.close_strategy_state(session.session_id, sym) state.delete_session(session.session_id) return { "deleted": True, "session_id": session_id, "positions_closed": orders_closed, "broker_error": broker_error, "fill_prices": fill_prices, } # ── Data endpoints ───────────────────────────────────────────────────────────── @router.get("/sessions/{session_id}/positions") def get_positions(session_id: str) -> dict[str, Any]: state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") strategy_states = { ss.symbol: ss for ss in state.get_open_strategy_states(session.session_id) } parking_state = state.get_parking_state(session.session_id) parking_symbol = parking_state["symbol"].upper() if parking_state else None # Aggregate open trades by symbol so multiple fills for the same symbol # (common when sessions spread entries over several fills) are combined. open_trades: dict[str, dict] = {} for t in state.list_trades(session.session_id): if t.get("exit_date") is not None: continue sym = t["symbol"] if sym == (parking_symbol or ""): continue shares = t.get("shares") or 0 entry = float(t.get("entry_price") or 0.0) if sym not in open_trades: open_trades[sym] = {"shares": 0, "cost": 0.0} open_trades[sym]["shares"] += shares open_trades[sym]["cost"] += shares * entry # Compute weighted-avg entry price for sym, data in open_trades.items(): data["entry_price"] = data["cost"] / data["shares"] if data["shares"] > 0 else 0.0 try: broker = _get_broker() positions = broker.list_positions() broker_by_symbol = {p.symbol: p for p in positions} result = [] for p in sorted(positions, key=lambda x: x.symbol): ss = strategy_states.get(p.symbol) is_parking = parking_symbol and p.symbol == parking_symbol if ss is None and not is_parking: continue # Only show positions tracked by this session # Use locally-recorded entry price/qty to avoid cross-session contamination # of Alpaca's blended avg_entry_price (multiple sessions share one broker account). ot = open_trades.get(p.symbol) if not is_parking else None if is_parking and parking_state: qty = float(parking_state.get("qty") or p.qty) entry = float(parking_state["avg_price"]) if parking_state.get("avg_price") else ( float(p.avg_entry_price) if p.avg_entry_price else 0.0 ) else: qty = float(ot["shares"]) if ot and ot.get("shares") else float(p.qty) entry = float(ot["entry_price"]) if ot and ot.get("entry_price") else ( float(p.avg_entry_price) if p.avg_entry_price else 0.0 ) cur_price = float(p.current_price) if p.current_price else None pnl = (cur_price - entry) * qty if cur_price and entry and qty else float(p.unrealized_pl) pnl_pct = pnl / (entry * qty) * 100 if entry and qty else 0.0 result.append({ "symbol": p.symbol, "qty": qty, "avg_entry_price": entry, "current_price": cur_price, "unrealized_pl": pnl, "unrealized_pl_pct": pnl_pct, "days_held": ss.days_held if ss else None, "stop_price": ss.current_stop if ss else None, "target_price": ss.target_price if ss else None, "entry_date": ss.entry_date if ss else (parking_state["entry_date"] if is_parking else None), "engine_id": ss.engine_id if ss else ("parking" if is_parking else None), "trade_direction": ss.trade_direction if ss else None, "_parking": bool(is_parking and not ss), }) # Include strategy states missing from broker (ghost positions) for sym, ss in strategy_states.items(): if sym not in broker_by_symbol: result.append({ "symbol": sym, "qty": None, "avg_entry_price": None, "current_price": None, "unrealized_pl": None, "unrealized_pl_pct": None, "days_held": ss.days_held, "stop_price": ss.current_stop, "target_price": ss.target_price, "entry_date": ss.entry_date, "engine_id": ss.engine_id, "trade_direction": ss.trade_direction, "_ghost": True, }) # Include parking if not already in broker positions if parking_symbol and parking_symbol not in broker_by_symbol: result.append({ "symbol": parking_symbol, "qty": parking_state.get("qty"), "avg_entry_price": parking_state.get("avg_price"), "current_price": None, "unrealized_pl": None, "unrealized_pl_pct": None, "days_held": None, "stop_price": None, "target_price": None, "entry_date": parking_state.get("entry_date"), "engine_id": "parking", "trade_direction": None, "_parking": True, "_ghost": True, }) return {"positions": result, "broker_available": True} except Exception as exc: result = [] for sym, ss in sorted(strategy_states.items()): result.append({ "symbol": sym, "qty": None, "avg_entry_price": None, "current_price": None, "unrealized_pl": None, "unrealized_pl_pct": None, "days_held": ss.days_held, "stop_price": ss.current_stop, "target_price": ss.target_price, "entry_date": ss.entry_date, "engine_id": ss.engine_id, "trade_direction": ss.trade_direction, }) if parking_state: result.append({ "symbol": parking_symbol, "qty": parking_state.get("qty"), "avg_entry_price": parking_state.get("avg_price"), "current_price": None, "unrealized_pl": None, "unrealized_pl_pct": None, "days_held": None, "stop_price": None, "target_price": None, "entry_date": parking_state.get("entry_date"), "engine_id": "parking", "trade_direction": None, "_parking": True, "_ghost": True, }) return {"positions": result, "broker_available": False, "broker_error": str(exc)} @router.get("/sessions/{session_id}/trades") def get_trades( session_id: str, last: int | None = Query(None, description="Show last N trades"), ) -> dict[str, Any]: state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") import datetime trades = state.list_trades(session.session_id, limit=last) # Include parking entries as fallback only when trades table has no record for that symbol # (old sessions before open_trade was added to _parking_buy). symbols_in_trades = {t["symbol"] for t in trades} parking_entries = state.list_parking_entries(session.session_id) today = datetime.date.today() for p in parking_entries: if p["symbol"] in symbols_in_trades: continue # already recorded via open_trade / record_trade entry_date = p.get("entry_date", "") try: days = (today - datetime.date.fromisoformat(entry_date)).days except Exception: days = 0 is_active = p.get("status") == "active" trades.append({ "trade_id": f"parking_{session_id}_{entry_date}_{p['symbol']}", "session_id": session_id, "symbol": p["symbol"], "entry_date": entry_date, "exit_date": None if is_active else entry_date, "entry_price": p.get("avg_price"), "exit_price": None, "exit_reason": "active" if is_active else "closed", "shares": p.get("qty"), "net_pnl": None, "r_multiple": None, "holding_days": days, "engine_id": "cash_parking", "capital_bucket_id": "parking", }) return {"trades": trades, "total": len(trades)} @router.get("/sessions/{session_id}/equity") def get_equity(session_id: str) -> dict[str, Any]: state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") snapshots = state.list_snapshots(session.session_id) return {"snapshots": snapshots, "initial_equity": session.initial_equity} # ── Run tasks (in-process) ──────────────────────────────────────────────────── class RunRequest(BaseModel): date: str | None = None force: bool = False @router.post("/sessions/{session_id}/run") async def run_daily(session_id: str, req: RunRequest = RunRequest()) -> dict[str, Any]: state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") return await svc.run_task(session, "run", _get_db_path(), date=req.date, force=req.force) @router.post("/sessions/{session_id}/run-close") async def run_close(session_id: str, req: RunRequest = RunRequest()) -> dict[str, Any]: """장 마감 직전: same-day 이벤트 → MOC 매수.""" state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") return await svc.run_task(session, "run-close", _get_db_path(), date=req.date, force=req.force) @router.post("/sessions/{session_id}/run-open") async def run_open(session_id: str, req: RunRequest = RunRequest()) -> dict[str, Any]: """장 시작 직후: 전날 exit + after-close 이벤트 → 시장가 매수.""" state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") return await svc.run_task(session, "run-open", _get_db_path(), date=req.date, force=req.force) @router.post("/run-all") async def run_all_sessions() -> dict[str, Any]: """Run daily processing for all active sessions.""" state = _get_state_manager() active = [s for s in state.list_sessions() if s.status == "active"] if not active: return {"tasks": [], "message": "No active sessions found"} task = await svc.run_all_task(active, _get_db_path()) return {"tasks": [task]} # ── Task management ──────────────────────────────────────────────────────────── @router.get("/tasks") def list_tasks(session_name: str | None = Query(None)) -> dict[str, Any]: tasks = svc.list_tasks(session_name=session_name) # Don't send full log in list view slim = [{k: v for k, v in t.items() if k != "log"} for t in tasks] return {"tasks": slim} @router.get("/tasks/{task_id}") def get_task(task_id: str) -> dict[str, Any]: task = svc.get_task(task_id) if task is None: raise HTTPException(status_code=404, detail="Task not found") return dict(task) @router.get("/tasks/{task_id}/log") def get_task_log(task_id: str) -> dict[str, Any]: result = svc.get_task_log(task_id) if result is None: raise HTTPException(status_code=404, detail="Task not found") return result # ── Utility ──────────────────────────────────────────────────────────────────── @router.get("/configs") def list_configs( status: str | None = Query(None, description="Filter by status (active/retired)"), pattern: str | None = Query(None, description="Filter by name pattern"), limit: int = Query(200, le=1000), ) -> dict[str, Any]: """List available experiment configs for session creation.""" configs_dir = get_project_root() / "configs" / "experiments" if not configs_dir.exists(): return {"configs": [], "total": 0} import json index_path = configs_dir / ".index.json" configs = [] if index_path.exists(): try: index = json.loads(index_path.read_text()) for name, meta in sorted(index.get("experiments", {}).items(), key=lambda x: x[0]): cfg_path = configs_dir / f"{name}.json" if not cfg_path.exists(): continue s = meta.get("status", "active") if status and s != status: continue if pattern and pattern.lower() not in name.lower(): continue configs.append({ "name": name, "path": f"configs/experiments/{name}.json", "id": meta.get("id"), "status": s, "version_family": meta.get("version_family"), "generation": meta.get("generation"), "parent": meta.get("parent"), }) except Exception: pass if not configs: for f in sorted(configs_dir.glob("*.json")): if f.name.startswith("."): continue if pattern and pattern.lower() not in f.stem.lower(): continue configs.append({ "name": f.stem, "path": f"configs/experiments/{f.name}", "id": None, "status": "active", "version_family": None, "generation": None, "parent": None, }) total = len(configs) return {"configs": configs[:limit], "total": total} # ── Auto scheduler endpoints ────────────────────────────────────────────────── class AutoStartRequest(BaseModel): sessions: list[str] = [] # empty = all active dry_run: bool = False @router.get("/auto") def get_auto_status() -> dict[str, Any]: """Get auto scheduler status + today's schedule.""" scheduler = svc.auto_scheduler return { "running": scheduler.running, "pid": None, # in-process: no separate PID "source": "in_process" if scheduler.running else None, "schedule": _auto_schedule_info(), "log_tail": scheduler.get_log_tail(80), "log_lines": scheduler.log_line_count, } @router.post("/auto/start") async def start_auto(req: AutoStartRequest) -> dict[str, Any]: """Start the in-process auto scheduler. Returns 409 if already running.""" scheduler = svc.auto_scheduler if scheduler.running: raise HTTPException( status_code=409, detail="Auto scheduler already running. Stop it first.", ) scheduler.start(sessions=req.sessions, dry_run=req.dry_run, db_path=_get_db_path()) return {"started": True, "pid": None, "dry_run": req.dry_run, "sessions": req.sessions} @router.post("/auto/stop") async def stop_auto() -> dict[str, Any]: """Stop the in-process auto scheduler.""" scheduler = svc.auto_scheduler if not scheduler.running: raise HTTPException(status_code=409, detail="Auto scheduler is not running.") scheduler.stop() return {"stopped": True, "pid": None} @router.get("/auto/log") def get_auto_log(lines: int = Query(200, le=2000)) -> dict[str, Any]: """Get the last N lines of the auto scheduler log.""" scheduler = svc.auto_scheduler return { "log": scheduler.get_log(lines), "lines": scheduler.log_line_count, "source": "in_process", }