package operator import ( "encoding/json" "fmt" "io" ) // OutputFormat selects how runner events are rendered to stdout. type OutputFormat string const ( // OutputText emits grep-friendly key=value lines (the default). OutputText OutputFormat = "text" // OutputJSONL emits one JSON object per line. OutputJSONL OutputFormat = "jsonl" ) // noCount marks a step event that has no market result count (for example a // hello handshake or a transport failure). const noCount = -1 // StepEvent is one machine-readable record describing a single executed step. type StepEvent struct { Scenario string Step string Action string Status string Count int ErrorCode string ErrorMessage string // Backtest-specific output fields RunID string RunStatus string StartingCash string EndingEquity string TotalReturn string TradeCount int // Import-specific output fields Provider string InstrumentCount int BarCount int // Paper-trading-specific output fields. They are only rendered when // AccountID is set, so non-paper steps never emit them. AccountID string Cash string PositionCount int FillCount int Risk string // Paper risk summary. Risk is "clear" when no order was rejected and // "blocked" otherwise. RiskRejectedCount and RiskReason are only rendered for // a blocked run so a clear run keeps stable, minimal output. RiskRejectedCount int RiskReason string // Paper loop equity summary. EquityPointCount is the length of the paper // equity curve and LatestEquity is the equity amount of its last point. // They are omitted for an empty curve so loop smoke evidence stays stable. EquityPointCount int LatestEquity string // Paper order lifecycle output fields. They render for submit/cancel/fill // steps, keyed on OrderID, independently of the paper-state account block. // OrderFillPrice/OrderFillCount carry the fill summary of a filled order. OrderID string OrderStatus string OrderReason string OrderFillPrice string OrderFillCount int } // RunSummary is the final record describing a whole scenario run. type RunSummary struct { Scenario string Status string Steps int Passed int ExitCode int } // Writer renders runner events to an io.Writer in the selected format. stdout // carries these machine-readable lines; scripts grep them for status keys. type Writer struct { w io.Writer format OutputFormat } // NewWriter returns a Writer for the given format, defaulting to text. func NewWriter(w io.Writer, format OutputFormat) *Writer { if format == "" { format = OutputText } return &Writer{w: w, format: format} } // WriteStep renders one executed step. func (o *Writer) WriteStep(ev StepEvent) { if o.format == OutputJSONL { fields := map[string]any{ "type": "step", "scenario": ev.Scenario, "step": ev.Step, "action": ev.Action, "status": ev.Status, } if ev.Count >= 0 { fields["count"] = ev.Count } if ev.ErrorCode != "" { fields["error_code"] = ev.ErrorCode } if ev.ErrorMessage != "" { fields["error_message"] = ev.ErrorMessage } if ev.RunID != "" { fields["run_id"] = ev.RunID } if ev.RunStatus != "" { fields["run_status"] = ev.RunStatus } if ev.StartingCash != "" { fields["starting_cash"] = ev.StartingCash } if ev.EndingEquity != "" { fields["ending_equity"] = ev.EndingEquity } if ev.TotalReturn != "" { fields["total_return"] = ev.TotalReturn } if ev.TradeCount > 0 { fields["trade_count"] = ev.TradeCount } if ev.Provider != "" { fields["provider"] = ev.Provider } if ev.InstrumentCount >= 0 { fields["instrument_count"] = ev.InstrumentCount } if ev.BarCount >= 0 { fields["bar_count"] = ev.BarCount } if ev.AccountID != "" { fields["account_id"] = ev.AccountID if ev.Cash != "" { fields["cash"] = ev.Cash } fields["position_count"] = ev.PositionCount fields["fill_count"] = ev.FillCount if ev.Risk != "" { fields["risk"] = ev.Risk } if ev.RiskRejectedCount > 0 { fields["risk_rejected_count"] = ev.RiskRejectedCount } if ev.RiskReason != "" { fields["risk_reason"] = ev.RiskReason } if ev.EquityPointCount > 0 { fields["equity_point_count"] = ev.EquityPointCount } if ev.LatestEquity != "" { fields["latest_equity"] = ev.LatestEquity } } if ev.OrderID != "" { fields["order_id"] = ev.OrderID if ev.OrderStatus != "" { fields["order_status"] = ev.OrderStatus } if ev.OrderReason != "" { fields["order_reason"] = ev.OrderReason } if ev.Action == string(ActionFillPaperOrder) { fields["fill_count"] = ev.OrderFillCount } if ev.OrderFillPrice != "" { fields["fill_price"] = ev.OrderFillPrice } } o.writeJSON(fields) return } line := fmt.Sprintf("scenario=%s step=%s action=%s status=%s", ev.Scenario, ev.Step, ev.Action, ev.Status) if ev.Count >= 0 { line += fmt.Sprintf(" count=%d", ev.Count) } if ev.ErrorCode != "" { line += fmt.Sprintf(" error.code=%s", ev.ErrorCode) } if ev.ErrorMessage != "" { line += fmt.Sprintf(" error.message=%q", ev.ErrorMessage) } if ev.RunID != "" { line += fmt.Sprintf(" run.id=%s", ev.RunID) } if ev.RunStatus != "" { line += fmt.Sprintf(" run.status=%s", ev.RunStatus) } if ev.StartingCash != "" { line += fmt.Sprintf(" starting_cash=%s", ev.StartingCash) } if ev.EndingEquity != "" { line += fmt.Sprintf(" ending_equity=%s", ev.EndingEquity) } if ev.TotalReturn != "" { line += fmt.Sprintf(" total_return=%s", ev.TotalReturn) } if ev.TradeCount > 0 { line += fmt.Sprintf(" trade_count=%d", ev.TradeCount) } if ev.Provider != "" { line += fmt.Sprintf(" provider=%s", ev.Provider) } if ev.InstrumentCount >= 0 { line += fmt.Sprintf(" instrument_count=%d", ev.InstrumentCount) } if ev.BarCount >= 0 { line += fmt.Sprintf(" bar_count=%d", ev.BarCount) } if ev.AccountID != "" { line += fmt.Sprintf(" account_id=%s", ev.AccountID) if ev.Cash != "" { line += fmt.Sprintf(" cash=%s", ev.Cash) } line += fmt.Sprintf(" position_count=%d fill_count=%d", ev.PositionCount, ev.FillCount) if ev.Risk != "" { line += fmt.Sprintf(" risk=%s", ev.Risk) } if ev.RiskRejectedCount > 0 { line += fmt.Sprintf(" risk_rejected_count=%d", ev.RiskRejectedCount) } if ev.RiskReason != "" { line += fmt.Sprintf(" risk_reason=%q", ev.RiskReason) } if ev.EquityPointCount > 0 { line += fmt.Sprintf(" equity_point_count=%d", ev.EquityPointCount) } if ev.LatestEquity != "" { line += fmt.Sprintf(" latest_equity=%s", ev.LatestEquity) } } if ev.OrderID != "" { line += fmt.Sprintf(" order_id=%s", ev.OrderID) if ev.OrderStatus != "" { line += fmt.Sprintf(" order_status=%s", ev.OrderStatus) } if ev.OrderReason != "" { line += fmt.Sprintf(" order_reason=%q", ev.OrderReason) } if ev.Action == string(ActionFillPaperOrder) { line += fmt.Sprintf(" fill_count=%d", ev.OrderFillCount) } if ev.OrderFillPrice != "" { line += fmt.Sprintf(" fill_price=%s", ev.OrderFillPrice) } } fmt.Fprintln(o.w, line) } // WriteSummary renders the final run summary. func (o *Writer) WriteSummary(s RunSummary) { if o.format == OutputJSONL { o.writeJSON(map[string]any{ "type": "summary", "scenario": s.Scenario, "status": s.Status, "steps": s.Steps, "passed": s.Passed, "exit_code": s.ExitCode, }) return } fmt.Fprintf(o.w, "scenario=%s status=%s steps=%d passed=%d exit_code=%d\n", s.Scenario, s.Status, s.Steps, s.Passed, s.ExitCode) } func (o *Writer) writeJSON(fields map[string]any) { data, err := json.Marshal(fields) if err != nil { // Marshalling a map of strings/ints cannot realistically fail; fall back // to a minimal line so a step result is never silently dropped. fmt.Fprintf(o.w, "{\"type\":\"error\",\"error_message\":%q}\n", err.Error()) return } fmt.Fprintln(o.w, string(data)) }