실거래 주문이 브로커에 도달하기 전에 kill switch와 계정별 주문 한도를 검증해야 한다. API, CLI, client parser surface와 완료된 review archive를 함께 정리한다.
376 lines
11 KiB
Go
376 lines
11 KiB
Go
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
|
|
|
|
// Live order lifecycle output fields. They render for submit/cancel/get live
|
|
// order steps, keyed on LiveOrderID. BrokerOrderID and BrokerStatus are the
|
|
// raw IDs and status strings the broker returned.
|
|
LiveOrderID string
|
|
LiveOrderStatus string
|
|
BrokerOrderID string
|
|
BrokerStatus string
|
|
OperatorConfirmed bool
|
|
|
|
// Live kill switch output fields. They render for get/set_live_kill_switch steps.
|
|
KillSwitchHalted bool
|
|
KillSwitchReason string
|
|
|
|
// Live risk policy output fields. They render for get_live_risk_policy steps.
|
|
MaxDailyOrders int
|
|
MaxOpenOrders int
|
|
AllowShortSelling bool
|
|
|
|
// Live risk decision output fields. They render for submit_live_order steps
|
|
// when a LiveRiskDecision is present in the response (i.e. risk-blocked submit).
|
|
HasRiskDecision bool
|
|
RiskDecisionAllow bool
|
|
RiskDecisionReason string
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
if ev.LiveOrderID != "" {
|
|
fields["live_order_id"] = ev.LiveOrderID
|
|
if ev.LiveOrderStatus != "" {
|
|
fields["live_order_status"] = ev.LiveOrderStatus
|
|
}
|
|
if ev.BrokerOrderID != "" {
|
|
fields["broker_order_id"] = ev.BrokerOrderID
|
|
}
|
|
if ev.BrokerStatus != "" {
|
|
fields["broker_status"] = ev.BrokerStatus
|
|
}
|
|
if ev.OperatorConfirmed {
|
|
fields["operator_confirmed"] = ev.OperatorConfirmed
|
|
}
|
|
}
|
|
if ev.Action == string(ActionGetLiveKillSwitch) || ev.Action == string(ActionSetLiveKillSwitch) {
|
|
fields["kill_switch_halted"] = ev.KillSwitchHalted
|
|
if ev.KillSwitchReason != "" {
|
|
fields["kill_switch_reason"] = ev.KillSwitchReason
|
|
}
|
|
}
|
|
if ev.Action == string(ActionGetLiveRiskPolicy) {
|
|
fields["max_daily_orders"] = ev.MaxDailyOrders
|
|
fields["max_open_orders"] = ev.MaxOpenOrders
|
|
fields["allow_short_selling"] = ev.AllowShortSelling
|
|
}
|
|
if ev.HasRiskDecision {
|
|
fields["risk_decision_allowed"] = ev.RiskDecisionAllow
|
|
if ev.RiskDecisionReason != "" {
|
|
fields["risk_decision_reason"] = ev.RiskDecisionReason
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
if ev.LiveOrderID != "" {
|
|
line += fmt.Sprintf(" live_order_id=%s", ev.LiveOrderID)
|
|
if ev.LiveOrderStatus != "" {
|
|
line += fmt.Sprintf(" live_order_status=%s", ev.LiveOrderStatus)
|
|
}
|
|
if ev.BrokerOrderID != "" {
|
|
line += fmt.Sprintf(" broker_order_id=%s", ev.BrokerOrderID)
|
|
}
|
|
if ev.BrokerStatus != "" {
|
|
line += fmt.Sprintf(" broker_status=%s", ev.BrokerStatus)
|
|
}
|
|
if ev.OperatorConfirmed {
|
|
line += " operator_confirmed=true"
|
|
}
|
|
}
|
|
if ev.Action == string(ActionGetLiveKillSwitch) || ev.Action == string(ActionSetLiveKillSwitch) {
|
|
line += fmt.Sprintf(" kill_switch_halted=%v", ev.KillSwitchHalted)
|
|
if ev.KillSwitchReason != "" {
|
|
line += fmt.Sprintf(" kill_switch_reason=%q", ev.KillSwitchReason)
|
|
}
|
|
}
|
|
if ev.Action == string(ActionGetLiveRiskPolicy) {
|
|
line += fmt.Sprintf(" max_daily_orders=%d max_open_orders=%d allow_short_selling=%v", ev.MaxDailyOrders, ev.MaxOpenOrders, ev.AllowShortSelling)
|
|
}
|
|
if ev.HasRiskDecision {
|
|
line += fmt.Sprintf(" risk_decision_allowed=%v", ev.RiskDecisionAllow)
|
|
if ev.RiskDecisionReason != "" {
|
|
line += fmt.Sprintf(" risk_decision_reason=%q", ev.RiskDecisionReason)
|
|
}
|
|
}
|
|
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))
|
|
}
|