You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
579 lines
23 KiB
Python
579 lines
23 KiB
Python
"""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
|
|
current = float(latest["equity"]) if latest else initial
|
|
peak = state.get_peak_equity(session.session_id, initial)
|
|
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
|
|
trades = state.list_trades(session.session_id)
|
|
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 all 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")
|
|
|
|
orders_closed = 0
|
|
broker_error: str | None = None
|
|
try:
|
|
broker = _get_broker()
|
|
orders = broker.close_all_positions()
|
|
orders_closed = len(orders)
|
|
except Exception as exc:
|
|
broker_error = str(exc)
|
|
|
|
for ss in state.get_open_strategy_states(session.session_id):
|
|
state.close_strategy_state(session.session_id, ss.symbol)
|
|
|
|
state.delete_session(session.session_id)
|
|
return {
|
|
"deleted": True,
|
|
"session_id": session_id,
|
|
"positions_closed": orders_closed,
|
|
"broker_error": broker_error,
|
|
}
|
|
|
|
|
|
# ── 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)
|
|
}
|
|
|
|
try:
|
|
broker = _get_broker()
|
|
positions = broker.list_positions()
|
|
result = []
|
|
for p in sorted(positions, key=lambda x: x.symbol):
|
|
ss = strategy_states.get(p.symbol)
|
|
if ss is None:
|
|
continue # Only show positions tracked by this session
|
|
qty = float(p.qty)
|
|
entry = float(p.avg_entry_price) if p.avg_entry_price else 0.0
|
|
pnl = 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": float(p.current_price) if p.current_price else None,
|
|
"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 None,
|
|
"engine_id": ss.engine_id if ss else None,
|
|
"trade_direction": ss.trade_direction if ss else None,
|
|
})
|
|
# Include strategy states missing from broker (ghost positions)
|
|
broker_symbols = {p.symbol for p in positions}
|
|
for sym, ss in strategy_states.items():
|
|
if sym not in broker_symbols:
|
|
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,
|
|
})
|
|
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,
|
|
})
|
|
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 (active + closed) as parking-sleeve trades
|
|
parking_entries = state.list_parking_entries(session.session_id)
|
|
today = datetime.date.today()
|
|
for p in parking_entries:
|
|
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",
|
|
}
|