실거래 주문 생성/조회/취소 흐름이 contracts, worker, API, CLI, Flutter parser까지 같은 메시지 경계로 이어져야 한다.\n\n브로커 호출 전 operator confirmation과 malformed order validation을 worker-owned runtime에서 막고, 리뷰 완료 산출물을 archive에 보존한다.
924 lines
29 KiB
Go
924 lines
29 KiB
Go
package operator
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
|
|
)
|
|
|
|
// Run command exit codes. They form the terminal contract scripts rely on to
|
|
// tell a healthy run, a typed/expectation failure, bad input, and a dead
|
|
// transport apart.
|
|
const (
|
|
codeOK = 0 // every step matched its expectation
|
|
codeAppError = 1 // unexpected typed ErrorInfo or expectation mismatch
|
|
codeBadInput = 2 // unsupported action reached the runner
|
|
codeTransport = 3 // connect/disconnect/timeout before a typed response
|
|
)
|
|
|
|
// Step status values written to the output stream.
|
|
const (
|
|
statusOK = "ok"
|
|
statusError = "error"
|
|
statusMismatch = "mismatch"
|
|
statusTransportError = "transport_error"
|
|
)
|
|
|
|
// Run summary status values.
|
|
const (
|
|
summaryOK = "ok"
|
|
summaryFailed = "failed"
|
|
summaryTransport = "transport_error"
|
|
)
|
|
|
|
// Identity the CLI presents to the API in a hello handshake.
|
|
const (
|
|
clientName = "alt-operator-cli"
|
|
clientVersion = "dev"
|
|
altProtocolVersion = "alt.v1"
|
|
)
|
|
|
|
// marketByName maps scenario market strings onto the contract enum. The keys
|
|
// match scenario.go's validMarkets so a value accepted at validation always has
|
|
// a mapping here.
|
|
var marketByName = map[string]altv1.Market{
|
|
"": altv1.Market_MARKET_UNSPECIFIED,
|
|
"unspecified": altv1.Market_MARKET_UNSPECIFIED,
|
|
"kr": altv1.Market_MARKET_KR,
|
|
"us": altv1.Market_MARKET_US,
|
|
}
|
|
|
|
// timeframeByName maps scenario timeframe strings onto the contract enum.
|
|
var timeframeByName = map[string]altv1.Timeframe{
|
|
"daily": altv1.Timeframe_TIMEFRAME_DAILY,
|
|
"minute_1": altv1.Timeframe_TIMEFRAME_MINUTE_1,
|
|
"minute_5": altv1.Timeframe_TIMEFRAME_MINUTE_5,
|
|
}
|
|
|
|
// venueByName maps scenario venue strings onto the contract enum.
|
|
var venueByName = map[string]altv1.Venue{
|
|
"": altv1.Venue_VENUE_UNSPECIFIED,
|
|
"unspecified": altv1.Venue_VENUE_UNSPECIFIED,
|
|
"krx": altv1.Venue_VENUE_KRX,
|
|
"nasdaq": altv1.Venue_VENUE_NASDAQ,
|
|
"nyse": altv1.Venue_VENUE_NYSE,
|
|
}
|
|
|
|
// currencyByMarket derives the paper account currency from the request market so
|
|
// scenarios only declare a market and a starting cash amount. KR settles in KRW
|
|
// and US in USD; an unspecified market leaves the currency unspecified and the
|
|
// worker rejects it as an invalid request.
|
|
var currencyByMarket = map[string]altv1.Currency{
|
|
"kr": altv1.Currency_CURRENCY_KRW,
|
|
"us": altv1.Currency_CURRENCY_USD,
|
|
}
|
|
|
|
// RunOptions configures a scenario run.
|
|
type RunOptions struct {
|
|
// APIURL is the ws/wss endpoint of the API control plane.
|
|
APIURL string
|
|
// Timeout is the per-step request timeout. When zero, the scenario timeout
|
|
// is used, falling back to 5s.
|
|
Timeout time.Duration
|
|
// Format selects the output rendering.
|
|
Format OutputFormat
|
|
}
|
|
|
|
// RunScenario connects to the API, executes each step, writes machine-readable
|
|
// events to stdout, and returns the process exit code. A transport failure
|
|
// before any typed response yields codeTransport; an unexpected typed ErrorInfo
|
|
// or a failed expectation yields codeAppError.
|
|
func RunScenario(ctx context.Context, sc *Scenario, opts RunOptions, stdout io.Writer) int {
|
|
out := NewWriter(stdout, opts.Format)
|
|
|
|
timeout := opts.Timeout
|
|
if timeout <= 0 {
|
|
timeout = time.Duration(sc.Timeout)
|
|
}
|
|
if timeout <= 0 {
|
|
timeout = 5 * time.Second
|
|
}
|
|
|
|
dialCtx, cancelDial := context.WithTimeout(ctx, timeout)
|
|
client, err := Dial(dialCtx, opts.APIURL)
|
|
cancelDial()
|
|
if err != nil {
|
|
out.WriteStep(StepEvent{
|
|
Scenario: sc.Name,
|
|
Step: firstStepID(sc),
|
|
Action: firstStepAction(sc),
|
|
Status: statusTransportError,
|
|
Count: noCount,
|
|
InstrumentCount: noCount,
|
|
BarCount: noCount,
|
|
ErrorMessage: err.Error(),
|
|
})
|
|
out.WriteSummary(RunSummary{Scenario: sc.Name, Status: summaryTransport, Steps: len(sc.Steps), Passed: 0, ExitCode: codeTransport})
|
|
return codeTransport
|
|
}
|
|
defer client.Close()
|
|
|
|
passed := 0
|
|
exitCode := codeOK
|
|
savedValues := make(map[string]string)
|
|
for _, step := range sc.Steps {
|
|
ev, code, runID := runStep(ctx, client, sc.Name, step, timeout, savedValues)
|
|
if runID != "" {
|
|
savedValues[step.ID] = runID
|
|
if step.SaveAs != "" {
|
|
savedValues[step.SaveAs] = runID
|
|
}
|
|
}
|
|
|
|
// Post-process step expectations for expected exit code/transport status
|
|
if step.Expect.TransportStatus != "" {
|
|
if ev.Status == step.Expect.TransportStatus {
|
|
ev.Status = statusOK
|
|
code = codeOK
|
|
}
|
|
}
|
|
|
|
if step.Expect.ExitCode != nil {
|
|
if code == *step.Expect.ExitCode {
|
|
ev.Status = statusOK
|
|
code = codeOK
|
|
} else {
|
|
ev.Status = statusMismatch
|
|
ev.ErrorMessage = fmt.Sprintf("expected exit code %d, got %d", *step.Expect.ExitCode, code)
|
|
code = codeAppError
|
|
}
|
|
}
|
|
|
|
out.WriteStep(ev)
|
|
if code == codeOK {
|
|
passed++
|
|
} else if code > exitCode {
|
|
exitCode = code
|
|
}
|
|
if code == codeTransport {
|
|
// The socket dropped mid-run; remaining steps cannot be trusted.
|
|
break
|
|
}
|
|
}
|
|
|
|
out.WriteSummary(RunSummary{
|
|
Scenario: sc.Name,
|
|
Status: summaryStatus(exitCode),
|
|
Steps: len(sc.Steps),
|
|
Passed: passed,
|
|
ExitCode: exitCode,
|
|
})
|
|
return exitCode
|
|
}
|
|
|
|
func summaryStatus(exitCode int) string {
|
|
switch exitCode {
|
|
case codeOK:
|
|
return summaryOK
|
|
case codeTransport:
|
|
return summaryTransport
|
|
default:
|
|
return summaryFailed
|
|
}
|
|
}
|
|
|
|
func firstStepID(sc *Scenario) string {
|
|
if len(sc.Steps) > 0 {
|
|
return sc.Steps[0].ID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstStepAction(sc *Scenario) string {
|
|
if len(sc.Steps) > 0 {
|
|
return string(sc.Steps[0].Action)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// runStep executes one step and classifies the result into a StepEvent, an
|
|
// exit code, and an optional backtest run ID to save for interpolation.
|
|
func runStep(ctx context.Context, client *APIClient, scenario string, step Step, timeout time.Duration, savedValues map[string]string) (StepEvent, int, string) {
|
|
stepCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
ev := StepEvent{
|
|
Scenario: scenario,
|
|
Step: step.ID,
|
|
Action: string(step.Action),
|
|
Count: noCount,
|
|
InstrumentCount: noCount,
|
|
BarCount: noCount,
|
|
}
|
|
|
|
switch step.Action {
|
|
case ActionHello:
|
|
resp, err := client.Hello(stepCtx, &altv1.HelloRequest{
|
|
ClientName: clientName,
|
|
ClientVersion: clientVersion,
|
|
AltProtocolVersion: altProtocolVersion,
|
|
})
|
|
if err != nil {
|
|
return transportEvent3(ev, err)
|
|
}
|
|
ev2, code := evaluateHello(ev, step, resp)
|
|
return ev2, code, ""
|
|
|
|
case ActionListInstruments:
|
|
resp, err := client.ListInstruments(stepCtx, &altv1.ListInstrumentsRequest{
|
|
Market: marketByName[step.Request.Market],
|
|
})
|
|
if err != nil {
|
|
return transportEvent3(ev, err)
|
|
}
|
|
ev2, code := evaluateMarket(ev, step, resp.GetError(), len(resp.GetInstruments()))
|
|
return ev2, code, ""
|
|
|
|
case ActionImportDailyBars:
|
|
resp, err := client.ImportDailyBars(stepCtx, &altv1.ImportDailyBarsRequest{
|
|
Provider: step.Request.Provider,
|
|
SelectorKind: step.Request.SelectorKind,
|
|
Market: marketByName[step.Request.Market],
|
|
Venue: venueByName[step.Request.Venue],
|
|
Name: step.Request.Name,
|
|
Symbols: step.Request.Symbols,
|
|
FromYyyymmdd: step.Request.FromYYYYMMDD,
|
|
ToYyyymmdd: step.Request.ToYYYYMMDD,
|
|
})
|
|
if err != nil {
|
|
return transportEvent3(ev, err)
|
|
}
|
|
ev2, code := evaluateImport(ev, step, resp)
|
|
return ev2, code, ""
|
|
|
|
case ActionListBars:
|
|
resp, err := client.ListBars(stepCtx, &altv1.ListBarsRequest{
|
|
InstrumentId: step.Request.InstrumentID,
|
|
Timeframe: timeframeByName[step.Request.Timeframe],
|
|
FromUnixMs: step.Request.FromUnixMs,
|
|
ToUnixMs: step.Request.ToUnixMs,
|
|
})
|
|
if err != nil {
|
|
return transportEvent3(ev, err)
|
|
}
|
|
ev2, code := evaluateMarket(ev, step, resp.GetError(), len(resp.GetBars()))
|
|
return ev2, code, ""
|
|
|
|
case ActionStartBacktest:
|
|
resp, err := client.StartBacktest(stepCtx, &altv1.StartBacktestRequest{
|
|
Spec: &altv1.BacktestRunSpec{
|
|
StrategyId: step.Request.StrategyID,
|
|
Market: marketByName[step.Request.Market],
|
|
Timeframe: timeframeByName[step.Request.Timeframe],
|
|
FromUnixMs: step.Request.FromUnixMs,
|
|
ToUnixMs: step.Request.ToUnixMs,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return transportEvent3(ev, err)
|
|
}
|
|
return evaluateBacktest(ev, step, resp.GetError(), resp.GetRun(), nil, noCount)
|
|
|
|
case ActionListBacktestRuns:
|
|
var statusEnum altv1.BacktestRunStatus
|
|
if step.Request.Status != "" {
|
|
statusEnum = altv1.BacktestRunStatus(altv1.BacktestRunStatus_value["BACKTEST_RUN_STATUS_"+strings.ToUpper(step.Request.Status)])
|
|
}
|
|
resp, err := client.ListBacktestRuns(stepCtx, &altv1.ListBacktestRunsRequest{
|
|
Status: statusEnum,
|
|
})
|
|
if err != nil {
|
|
return transportEvent3(ev, err)
|
|
}
|
|
return evaluateBacktest(ev, step, resp.GetError(), nil, nil, len(resp.GetRuns()))
|
|
|
|
case ActionGetBacktestRunDetail:
|
|
runID := interpolate(step.Request.RunID, savedValues)
|
|
resp, err := client.GetBacktestRunDetail(stepCtx, &altv1.GetBacktestRunDetailRequest{
|
|
RunId: runID,
|
|
})
|
|
if err != nil {
|
|
return transportEvent3(ev, err)
|
|
}
|
|
return evaluateBacktest(ev, step, resp.GetError(), resp.GetRun(), resp.GetResult(), noCount)
|
|
|
|
case ActionGetBacktestResult:
|
|
runID := interpolate(step.Request.RunID, savedValues)
|
|
resp, err := client.GetBacktestResult(stepCtx, &altv1.GetBacktestResultRequest{
|
|
RunId: runID,
|
|
})
|
|
if err != nil {
|
|
return transportEvent3(ev, err)
|
|
}
|
|
return evaluateBacktest(ev, step, resp.GetError(), nil, resp.GetResult(), noCount)
|
|
|
|
case ActionCompareBacktestRuns:
|
|
runIDs := interpolateSlice(step.Request.RunIDs, savedValues)
|
|
resp, err := client.CompareBacktestRuns(stepCtx, &altv1.CompareBacktestRunsRequest{
|
|
RunIds: runIDs,
|
|
})
|
|
if err != nil {
|
|
return transportEvent3(ev, err)
|
|
}
|
|
return evaluateBacktest(ev, step, resp.GetError(), nil, nil, len(resp.GetResults()))
|
|
|
|
case ActionPollBacktestRun:
|
|
runID := interpolate(step.Request.RunID, savedValues)
|
|
interval := time.Duration(step.Request.PollingInterval)
|
|
if interval <= 0 {
|
|
interval = 500 * time.Millisecond
|
|
}
|
|
pollTimeout := time.Duration(step.Request.PollingTimeout)
|
|
if pollTimeout <= 0 {
|
|
pollTimeout = timeout
|
|
}
|
|
pollCtx, pollCancel := context.WithTimeout(ctx, pollTimeout)
|
|
defer pollCancel()
|
|
|
|
var lastResp *altv1.GetBacktestRunResponse
|
|
var lastErr error
|
|
|
|
for {
|
|
select {
|
|
case <-pollCtx.Done():
|
|
if lastResp != nil && lastResp.GetError() != nil {
|
|
return evaluateBacktest(ev, step, lastResp.GetError(), lastResp.GetRun(), nil, noCount)
|
|
}
|
|
ev.Status = statusTransportError
|
|
if lastErr != nil {
|
|
ev.ErrorMessage = fmt.Sprintf("polling timed out: %v (last error: %v)", pollCtx.Err(), lastErr)
|
|
} else {
|
|
ev.ErrorMessage = fmt.Sprintf("polling timed out: %v", pollCtx.Err())
|
|
}
|
|
return ev, codeTransport, ""
|
|
default:
|
|
}
|
|
|
|
resp, err := client.GetBacktestRun(pollCtx, &altv1.GetBacktestRunRequest{
|
|
RunId: runID,
|
|
})
|
|
if err != nil {
|
|
lastErr = err
|
|
select {
|
|
case <-pollCtx.Done():
|
|
case <-time.After(interval):
|
|
}
|
|
continue
|
|
}
|
|
|
|
lastResp = resp
|
|
if resp.GetError() != nil {
|
|
return evaluateBacktest(ev, step, resp.GetError(), resp.GetRun(), nil, noCount)
|
|
}
|
|
|
|
run := resp.GetRun()
|
|
if run != nil {
|
|
statusStr := strings.ToLower(strings.TrimPrefix(run.GetStatus().String(), "BACKTEST_RUN_STATUS_"))
|
|
isExpected := false
|
|
if step.Expect.RunStatus != "" {
|
|
isExpected = statusStr == strings.ToLower(step.Expect.RunStatus)
|
|
} else {
|
|
isExpected = statusStr == "succeeded" || statusStr == "failed" || statusStr == "canceled"
|
|
}
|
|
|
|
if isExpected {
|
|
return evaluateBacktest(ev, step, nil, run, nil, noCount)
|
|
}
|
|
}
|
|
|
|
select {
|
|
case <-pollCtx.Done():
|
|
case <-time.After(interval):
|
|
}
|
|
}
|
|
|
|
case ActionStartPaperTrading:
|
|
resp, err := client.StartPaperTrading(stepCtx, &altv1.StartPaperTradingRequest{
|
|
AccountId: step.Request.AccountID,
|
|
Spec: &altv1.BacktestRunSpec{
|
|
StrategyId: step.Request.StrategyID,
|
|
Market: marketByName[step.Request.Market],
|
|
Timeframe: timeframeByName[step.Request.Timeframe],
|
|
FromUnixMs: step.Request.FromUnixMs,
|
|
ToUnixMs: step.Request.ToUnixMs,
|
|
},
|
|
StartingCash: &altv1.Price{
|
|
Currency: currencyByMarket[step.Request.Market],
|
|
Amount: &altv1.Decimal{Value: step.Request.StartingCash},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return transportEvent3(ev, err)
|
|
}
|
|
ev2, code := evaluatePaper(ev, step, resp.GetError(), resp.GetState())
|
|
return ev2, code, ""
|
|
|
|
case ActionGetPaperTradingState:
|
|
resp, err := client.GetPaperTradingState(stepCtx, &altv1.GetPaperTradingStateRequest{
|
|
AccountId: step.Request.AccountID,
|
|
})
|
|
if err != nil {
|
|
return transportEvent3(ev, err)
|
|
}
|
|
ev2, code := evaluatePaper(ev, step, resp.GetError(), resp.GetState())
|
|
return ev2, code, ""
|
|
|
|
case ActionSubmitPaperOrder:
|
|
req := &altv1.SubmitPaperOrderRequest{
|
|
AccountId: step.Request.AccountID,
|
|
InstrumentId: step.Request.InstrumentID,
|
|
Side: step.Request.Side,
|
|
Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: step.Request.Quantity}},
|
|
Type: step.Request.OrderType,
|
|
}
|
|
if step.Request.OrderType == "limit" {
|
|
req.LimitPrice = &altv1.Price{
|
|
Currency: currencyByMarket[step.Request.Market],
|
|
Amount: &altv1.Decimal{Value: step.Request.LimitPrice},
|
|
}
|
|
}
|
|
resp, err := client.SubmitPaperOrder(stepCtx, req)
|
|
if err != nil {
|
|
return transportEvent3(ev, err)
|
|
}
|
|
return evaluatePaperOrder(ev, step, resp.GetError(), resp.GetOrder())
|
|
|
|
case ActionCancelPaperOrder:
|
|
orderID := interpolate(step.Request.OrderID, savedValues)
|
|
resp, err := client.CancelPaperOrder(stepCtx, &altv1.CancelPaperOrderRequest{
|
|
AccountId: step.Request.AccountID,
|
|
OrderId: orderID,
|
|
})
|
|
if err != nil {
|
|
return transportEvent3(ev, err)
|
|
}
|
|
return evaluatePaperOrder(ev, step, resp.GetError(), resp.GetOrder())
|
|
|
|
case ActionFillPaperOrder:
|
|
orderID := interpolate(step.Request.OrderID, savedValues)
|
|
fillReq := &altv1.FillPaperOrderRequest{
|
|
AccountId: step.Request.AccountID,
|
|
OrderId: orderID,
|
|
}
|
|
if step.Request.FillPrice != "" {
|
|
fillReq.FillPrice = &altv1.Price{
|
|
Currency: currencyByMarket[step.Request.Market],
|
|
Amount: &altv1.Decimal{Value: step.Request.FillPrice},
|
|
}
|
|
}
|
|
resp, err := client.FillPaperOrder(stepCtx, fillReq)
|
|
if err != nil {
|
|
return transportEvent3(ev, err)
|
|
}
|
|
return evaluatePaperOrder(ev, step, resp.GetError(), resp.GetOrder())
|
|
|
|
case ActionSubmitLiveOrder:
|
|
req := &altv1.SubmitLiveOrderRequest{
|
|
AccountId: step.Request.AccountID,
|
|
Intent: &altv1.LiveOrderIntent{
|
|
InstrumentId: step.Request.InstrumentID,
|
|
Side: step.Request.Side,
|
|
Type: step.Request.OrderType,
|
|
Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: step.Request.Quantity}},
|
|
},
|
|
OperatorConfirmation: &altv1.OperatorConfirmation{
|
|
Confirmed: step.Request.OperatorConfirmed,
|
|
OperatorId: step.Request.OperatorID,
|
|
Reason: step.Request.ConfirmationReason,
|
|
},
|
|
IdempotencyKey: step.Request.IdempotencyKey,
|
|
}
|
|
if step.Request.LimitPrice != "" {
|
|
req.Intent.LimitPrice = &altv1.Price{
|
|
Currency: currencyByMarket[step.Request.Market],
|
|
Amount: &altv1.Decimal{Value: step.Request.LimitPrice},
|
|
}
|
|
}
|
|
sresp, serr := client.SubmitLiveOrder(stepCtx, req)
|
|
if serr != nil {
|
|
return transportEvent3(ev, serr)
|
|
}
|
|
return evaluateLiveOrder(ev, step, sresp.GetError(), sresp.GetOrder())
|
|
|
|
case ActionCancelLiveOrder:
|
|
liveOrderID := interpolate(step.Request.LiveOrderID, savedValues)
|
|
cresp, cerr := client.CancelLiveOrder(stepCtx, &altv1.CancelLiveOrderRequest{
|
|
AccountId: step.Request.AccountID,
|
|
LiveOrderId: liveOrderID,
|
|
})
|
|
if cerr != nil {
|
|
return transportEvent3(ev, cerr)
|
|
}
|
|
return evaluateLiveOrder(ev, step, cresp.GetError(), cresp.GetOrder())
|
|
|
|
case ActionGetLiveOrder:
|
|
liveOrderID := interpolate(step.Request.LiveOrderID, savedValues)
|
|
gresp, gerr := client.GetLiveOrder(stepCtx, &altv1.GetLiveOrderRequest{
|
|
AccountId: step.Request.AccountID,
|
|
LiveOrderId: liveOrderID,
|
|
})
|
|
if gerr != nil {
|
|
return transportEvent3(ev, gerr)
|
|
}
|
|
return evaluateLiveOrder(ev, step, gresp.GetError(), gresp.GetOrder())
|
|
|
|
default:
|
|
// Validation rejects unknown actions, so reaching here means a runner is
|
|
// missing for a registered action.
|
|
ev.Status = statusError
|
|
ev.ErrorCode = "unsupported_action"
|
|
ev.ErrorMessage = fmt.Sprintf("no runner for action %q", step.Action)
|
|
return ev, codeBadInput, ""
|
|
}
|
|
}
|
|
|
|
func transportEvent(ev StepEvent, err error) (StepEvent, int) {
|
|
ev.Status = statusTransportError
|
|
ev.Count = noCount
|
|
ev.ErrorMessage = err.Error()
|
|
return ev, codeTransport
|
|
}
|
|
|
|
func transportEvent3(ev StepEvent, err error) (StepEvent, int, string) {
|
|
ev.Status = statusTransportError
|
|
ev.Count = noCount
|
|
ev.ErrorMessage = err.Error()
|
|
return ev, codeTransport, ""
|
|
}
|
|
|
|
// evaluateHello checks a hello response against the step expectation. Hello has
|
|
// no typed ErrorInfo, so an expected "error" status is itself a mismatch.
|
|
func evaluateHello(ev StepEvent, step Step, resp *altv1.HelloResponse) (StepEvent, int) {
|
|
if step.Expect.Status == statusError {
|
|
ev.Status = statusMismatch
|
|
ev.ErrorMessage = "hello cannot return a typed error but the step expected one"
|
|
return ev, codeAppError
|
|
}
|
|
if missing := missingCapabilities(step.Expect.Capabilities, resp.GetCapabilities()); missing != "" {
|
|
ev.Status = statusMismatch
|
|
ev.ErrorMessage = fmt.Sprintf("missing expected capability %q", missing)
|
|
return ev, codeAppError
|
|
}
|
|
ev.Status = statusOK
|
|
return ev, codeOK
|
|
}
|
|
|
|
// evaluateMarket checks a market response. errInfo is the typed ErrorInfo (nil
|
|
// when the call succeeded) and count is the number of returned results.
|
|
func evaluateMarket(ev StepEvent, step Step, errInfo *altv1.ErrorInfo, count int) (StepEvent, int) {
|
|
expectsError := step.Expect.Status == statusError
|
|
|
|
if errInfo != nil {
|
|
ev.ErrorCode = errInfo.GetCode()
|
|
ev.ErrorMessage = errInfo.GetMessage()
|
|
if !expectsError {
|
|
ev.Status = statusError
|
|
return ev, codeAppError
|
|
}
|
|
if step.Expect.ErrorCode != "" && step.Expect.ErrorCode != errInfo.GetCode() {
|
|
ev.Status = statusMismatch
|
|
return ev, codeAppError
|
|
}
|
|
// Expected typed error arrived: the step passed.
|
|
ev.Status = statusOK
|
|
return ev, codeOK
|
|
}
|
|
|
|
// Successful response with data.
|
|
ev.Count = count
|
|
if expectsError {
|
|
ev.Status = statusMismatch
|
|
ev.ErrorMessage = "expected a typed error but the request succeeded"
|
|
return ev, codeAppError
|
|
}
|
|
if step.Expect.MinCount != nil && count < *step.Expect.MinCount {
|
|
ev.Status = statusMismatch
|
|
ev.ErrorMessage = fmt.Sprintf("expected at least %d results, got %d", *step.Expect.MinCount, count)
|
|
return ev, codeAppError
|
|
}
|
|
ev.Status = statusOK
|
|
return ev, codeOK
|
|
}
|
|
|
|
// evaluateImport checks an import response.
|
|
func evaluateImport(ev StepEvent, step Step, resp *altv1.ImportDailyBarsResponse) (StepEvent, int) {
|
|
expectsError := step.Expect.Status == statusError
|
|
errInfo := resp.GetError()
|
|
|
|
// Default counts to noCount (-1) so they don't print for error steps unless set.
|
|
ev.InstrumentCount = noCount
|
|
ev.BarCount = noCount
|
|
|
|
if errInfo != nil {
|
|
ev.ErrorCode = errInfo.GetCode()
|
|
ev.ErrorMessage = errInfo.GetMessage()
|
|
if !expectsError {
|
|
ev.Status = statusError
|
|
return ev, codeAppError
|
|
}
|
|
if step.Expect.ErrorCode != "" && step.Expect.ErrorCode != errInfo.GetCode() {
|
|
ev.Status = statusMismatch
|
|
return ev, codeAppError
|
|
}
|
|
// Expected typed error arrived: the step passed.
|
|
ev.Status = statusOK
|
|
return ev, codeOK
|
|
}
|
|
|
|
ev.Provider = resp.GetProvider()
|
|
ev.InstrumentCount = int(resp.GetInstrumentCount())
|
|
ev.BarCount = int(resp.GetBarCount())
|
|
|
|
if expectsError {
|
|
ev.Status = statusMismatch
|
|
ev.ErrorMessage = "expected a typed error but the request succeeded"
|
|
return ev, codeAppError
|
|
}
|
|
if step.Expect.MinCount != nil && ev.BarCount < *step.Expect.MinCount {
|
|
ev.Status = statusMismatch
|
|
ev.ErrorMessage = fmt.Sprintf("expected at least %d results, got %d", *step.Expect.MinCount, ev.BarCount)
|
|
return ev, codeAppError
|
|
}
|
|
ev.Status = statusOK
|
|
return ev, codeOK
|
|
}
|
|
|
|
// evaluateBacktest checks a backtest response against expectations.
|
|
func evaluateBacktest(ev StepEvent, step Step, errInfo *altv1.ErrorInfo, run *altv1.BacktestRun, result *altv1.BacktestResult, count int) (StepEvent, int, string) {
|
|
expectsError := step.Expect.Status == statusError
|
|
var runID string
|
|
if run != nil {
|
|
runID = run.GetId()
|
|
ev.RunID = run.GetId()
|
|
ev.RunStatus = strings.ToLower(strings.TrimPrefix(run.GetStatus().String(), "BACKTEST_RUN_STATUS_"))
|
|
}
|
|
if result != nil {
|
|
runID = result.GetRunId()
|
|
ev.RunID = result.GetRunId()
|
|
if result.GetSummary() != nil {
|
|
ev.StartingCash = result.GetSummary().GetStartingCash().GetAmount().GetValue()
|
|
ev.EndingEquity = result.GetSummary().GetEndingEquity().GetAmount().GetValue()
|
|
ev.TotalReturn = result.GetSummary().GetTotalReturn().GetValue()
|
|
ev.TradeCount = int(result.GetSummary().GetTradeCount())
|
|
}
|
|
}
|
|
|
|
if errInfo != nil {
|
|
ev.ErrorCode = errInfo.GetCode()
|
|
ev.ErrorMessage = errInfo.GetMessage()
|
|
if !expectsError {
|
|
ev.Status = statusError
|
|
return ev, codeAppError, runID
|
|
}
|
|
if step.Expect.ErrorCode != "" && step.Expect.ErrorCode != errInfo.GetCode() {
|
|
ev.Status = statusMismatch
|
|
return ev, codeAppError, runID
|
|
}
|
|
ev.Status = statusOK
|
|
return ev, codeOK, runID
|
|
}
|
|
|
|
if expectsError {
|
|
ev.Status = statusMismatch
|
|
ev.ErrorMessage = "expected a typed error but the request succeeded"
|
|
return ev, codeAppError, runID
|
|
}
|
|
|
|
// Validate run status expectation if set
|
|
if step.Expect.RunStatus != "" && run != nil {
|
|
actualStatus := strings.ToLower(strings.TrimPrefix(run.GetStatus().String(), "BACKTEST_RUN_STATUS_"))
|
|
expectedStatus := strings.ToLower(step.Expect.RunStatus)
|
|
if actualStatus != expectedStatus {
|
|
ev.Status = statusMismatch
|
|
ev.ErrorMessage = fmt.Sprintf("expected backtest run status %q, got %q", expectedStatus, actualStatus)
|
|
return ev, codeAppError, runID
|
|
}
|
|
}
|
|
|
|
if step.Expect.MinCount != nil && count < *step.Expect.MinCount {
|
|
ev.Status = statusMismatch
|
|
ev.ErrorMessage = fmt.Sprintf("expected at least %d results, got %d", *step.Expect.MinCount, count)
|
|
return ev, codeAppError, runID
|
|
}
|
|
|
|
ev.Status = statusOK
|
|
if count >= 0 {
|
|
ev.Count = count
|
|
}
|
|
return ev, codeOK, runID
|
|
}
|
|
|
|
// evaluatePaper checks a paper trading response against expectations. errInfo is
|
|
// the typed ErrorInfo (nil when the call succeeded) and state carries the paper
|
|
// account snapshot rendered into stable text/JSONL fields.
|
|
func evaluatePaper(ev StepEvent, step Step, errInfo *altv1.ErrorInfo, state *altv1.PaperTradingState) (StepEvent, int) {
|
|
expectsError := step.Expect.Status == statusError
|
|
|
|
if errInfo != nil {
|
|
ev.ErrorCode = errInfo.GetCode()
|
|
ev.ErrorMessage = errInfo.GetMessage()
|
|
if !expectsError {
|
|
ev.Status = statusError
|
|
return ev, codeAppError
|
|
}
|
|
if step.Expect.ErrorCode != "" && step.Expect.ErrorCode != errInfo.GetCode() {
|
|
ev.Status = statusMismatch
|
|
return ev, codeAppError
|
|
}
|
|
ev.Status = statusOK
|
|
return ev, codeOK
|
|
}
|
|
|
|
if expectsError {
|
|
ev.Status = statusMismatch
|
|
ev.ErrorMessage = "expected a typed error but the request succeeded"
|
|
return ev, codeAppError
|
|
}
|
|
|
|
if state != nil {
|
|
ev.AccountID = state.GetAccountId()
|
|
ev.Cash = state.GetCash().GetAmount().GetValue()
|
|
ev.PositionCount = len(state.GetPositions())
|
|
ev.FillCount = len(state.GetFills())
|
|
// Risk is derived from the worker-owned rejection summary: an empty list
|
|
// means every decided order cleared risk, a non-empty list means at least
|
|
// one order was blocked. The first reason is surfaced so blocked output
|
|
// carries a stable, human-readable cause without faking broker behaviour.
|
|
ev.Risk = "clear"
|
|
if rejections := state.GetRiskRejections(); len(rejections) > 0 {
|
|
ev.Risk = "blocked"
|
|
ev.RiskRejectedCount = len(rejections)
|
|
ev.RiskReason = rejections[0].GetReason()
|
|
}
|
|
if run := state.GetRun(); run != nil {
|
|
ev.RunID = run.GetId()
|
|
ev.RunStatus = strings.ToLower(strings.TrimPrefix(run.GetStatus().String(), "BACKTEST_RUN_STATUS_"))
|
|
}
|
|
// Summarise the equity curve so loop smoke output carries a stable
|
|
// point count and the latest equity value. An empty curve leaves both
|
|
// fields zero/empty so they are omitted from text and JSONL.
|
|
if curve := state.GetEquityCurve(); len(curve) > 0 {
|
|
ev.EquityPointCount = len(curve)
|
|
ev.LatestEquity = curve[len(curve)-1].GetEquity().GetAmount().GetValue()
|
|
}
|
|
}
|
|
|
|
if step.Expect.RunStatus != "" && state.GetRun() != nil {
|
|
actualStatus := strings.ToLower(strings.TrimPrefix(state.GetRun().GetStatus().String(), "BACKTEST_RUN_STATUS_"))
|
|
expectedStatus := strings.ToLower(step.Expect.RunStatus)
|
|
if actualStatus != expectedStatus {
|
|
ev.Status = statusMismatch
|
|
ev.ErrorMessage = fmt.Sprintf("expected paper run status %q, got %q", expectedStatus, actualStatus)
|
|
return ev, codeAppError
|
|
}
|
|
}
|
|
|
|
ev.Status = statusOK
|
|
return ev, codeOK
|
|
}
|
|
|
|
// evaluatePaperOrder checks a paper order lifecycle response. order is the typed
|
|
// PaperOrder (nil on a typed error). The returned string is the order id, saved
|
|
// so a later cancel/fill step can interpolate {{steps.<id>.order.id}}. A risk or
|
|
// portfolio denial is a successful response carrying order_status=rejected, not a
|
|
// transport/typed error, so the operator sees the status transition.
|
|
func evaluatePaperOrder(ev StepEvent, step Step, errInfo *altv1.ErrorInfo, order *altv1.PaperOrder) (StepEvent, int, string) {
|
|
expectsError := step.Expect.Status == statusError
|
|
|
|
var orderID string
|
|
if order != nil {
|
|
orderID = order.GetOrderId()
|
|
ev.OrderID = order.GetOrderId()
|
|
ev.OrderStatus = order.GetStatus()
|
|
ev.OrderReason = order.GetReason()
|
|
if fill := order.GetFill(); fill != nil {
|
|
ev.OrderFillPrice = fill.GetPrice().GetAmount().GetValue()
|
|
ev.OrderFillCount = 1
|
|
}
|
|
}
|
|
|
|
if errInfo != nil {
|
|
ev.ErrorCode = errInfo.GetCode()
|
|
ev.ErrorMessage = errInfo.GetMessage()
|
|
if !expectsError {
|
|
ev.Status = statusError
|
|
return ev, codeAppError, orderID
|
|
}
|
|
if step.Expect.ErrorCode != "" && step.Expect.ErrorCode != errInfo.GetCode() {
|
|
ev.Status = statusMismatch
|
|
return ev, codeAppError, orderID
|
|
}
|
|
ev.Status = statusOK
|
|
return ev, codeOK, orderID
|
|
}
|
|
|
|
if expectsError {
|
|
ev.Status = statusMismatch
|
|
ev.ErrorMessage = "expected a typed error but the request succeeded"
|
|
return ev, codeAppError, orderID
|
|
}
|
|
|
|
if step.Expect.OrderStatus != "" && order != nil {
|
|
expectedStatus := strings.ToLower(step.Expect.OrderStatus)
|
|
if order.GetStatus() != expectedStatus {
|
|
ev.Status = statusMismatch
|
|
ev.ErrorMessage = fmt.Sprintf("expected order status %q, got %q", expectedStatus, order.GetStatus())
|
|
return ev, codeAppError, orderID
|
|
}
|
|
}
|
|
|
|
ev.Status = statusOK
|
|
return ev, codeOK, orderID
|
|
}
|
|
|
|
// evaluateLiveOrder checks a live order lifecycle response. order is the typed
|
|
// LiveOrder (nil on a typed error). The returned string is the live order id,
|
|
// saved so a later cancel/get step can interpolate {{steps.<id>.live_order.id}}.
|
|
// The live order status set is open (broker-defined strings), so only a non-empty
|
|
// expect.live_order_status is compared verbatim — no closed-set restriction.
|
|
func evaluateLiveOrder(ev StepEvent, step Step, errInfo *altv1.ErrorInfo, order *altv1.LiveOrder) (StepEvent, int, string) {
|
|
expectsError := step.Expect.Status == statusError
|
|
|
|
var liveOrderID string
|
|
if order != nil {
|
|
liveOrderID = order.GetId()
|
|
ev.LiveOrderID = order.GetId()
|
|
ev.LiveOrderStatus = order.GetStatus()
|
|
ev.BrokerOrderID = order.GetBrokerId()
|
|
ev.BrokerStatus = order.GetBrokerStatus()
|
|
}
|
|
|
|
if errInfo != nil {
|
|
ev.ErrorCode = errInfo.GetCode()
|
|
ev.ErrorMessage = errInfo.GetMessage()
|
|
if !expectsError {
|
|
ev.Status = statusError
|
|
return ev, codeAppError, liveOrderID
|
|
}
|
|
if step.Expect.ErrorCode != "" && step.Expect.ErrorCode != errInfo.GetCode() {
|
|
ev.Status = statusMismatch
|
|
return ev, codeAppError, liveOrderID
|
|
}
|
|
ev.Status = statusOK
|
|
return ev, codeOK, liveOrderID
|
|
}
|
|
|
|
if expectsError {
|
|
ev.Status = statusMismatch
|
|
ev.ErrorMessage = "expected a typed error but the request succeeded"
|
|
return ev, codeAppError, liveOrderID
|
|
}
|
|
|
|
if step.Expect.LiveOrderStatus != "" && order != nil {
|
|
if order.GetStatus() != step.Expect.LiveOrderStatus {
|
|
ev.Status = statusMismatch
|
|
ev.ErrorMessage = fmt.Sprintf("expected live order status %q, got %q", step.Expect.LiveOrderStatus, order.GetStatus())
|
|
return ev, codeAppError, liveOrderID
|
|
}
|
|
}
|
|
|
|
ev.Status = statusOK
|
|
return ev, codeOK, liveOrderID
|
|
}
|
|
|
|
// missingCapabilities returns the first wanted capability absent from have, or
|
|
// "" when all are present.
|
|
func missingCapabilities(want, have []string) string {
|
|
if len(want) == 0 {
|
|
return ""
|
|
}
|
|
set := make(map[string]bool, len(have))
|
|
for _, c := range have {
|
|
set[c] = true
|
|
}
|
|
for _, c := range want {
|
|
if !set[c] {
|
|
return c
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func interpolate(val string, savedValues map[string]string) string {
|
|
for k, v := range savedValues {
|
|
val = strings.ReplaceAll(val, fmt.Sprintf("{{steps.%s.run.id}}", k), v)
|
|
val = strings.ReplaceAll(val, fmt.Sprintf("{{steps.%s.order.id}}", k), v)
|
|
val = strings.ReplaceAll(val, fmt.Sprintf("{{steps.%s.live_order.id}}", k), v)
|
|
}
|
|
return val
|
|
}
|
|
|
|
func interpolateSlice(slice []string, savedValues map[string]string) []string {
|
|
if len(slice) == 0 {
|
|
return nil
|
|
}
|
|
res := make([]string, len(slice))
|
|
for i, v := range slice {
|
|
res[i] = interpolate(v, savedValues)
|
|
}
|
|
return res
|
|
}
|