Files
Veyra/internal/service/backtest.go
T
Blizzard 92329ddce2
Checks / verify (push) Failing after 9m52s
feat: 完成模拟交易闭环、App 与 A 股行情接入
统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
2026-10-08 22:58:52 +08:00

156 lines
5.2 KiB
Go

package service
import (
"context"
"encoding/json"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"github.com/google/uuid"
"go.uber.org/zap"
"strings"
"time"
"unicode/utf8"
)
type BacktestRepository interface {
Create(context.Context, model.BacktestInput, string, string) (model.BacktestRun, error)
List(context.Context) ([]model.BacktestRun, error)
Detail(context.Context, string) (model.BacktestRun, error)
Claim(context.Context) (model.BacktestRun, error)
Finish(context.Context, model.BacktestRun, json.RawMessage, string) error
}
type ResearchEngine interface {
Execute(context.Context, json.RawMessage) (json.RawMessage, error)
}
type BacktestService struct {
repo BacktestRepository
engine ResearchEngine
development bool
market *MarketService
}
func NewBacktestService(repo BacktestRepository, engine ResearchEngine, development bool, markets ...*MarketService) *BacktestService {
s := &BacktestService{repo: repo, engine: engine, development: development}
if len(markets) > 0 {
s.market = markets[0]
}
return s
}
func (s *BacktestService) authorize(u model.User) error {
if u.Role != "ADMIN" || u.Status != "ACTIVE" {
return apperror.ErrForbidden
}
if s == nil || s.repo == nil || !s.development {
return apperror.ErrUnavailable
}
return nil
}
func (s *BacktestService) List(ctx context.Context, u model.User) ([]model.BacktestRun, error) {
if e := s.authorize(u); e != nil {
return nil, e
}
return s.repo.List(ctx)
}
func (s *BacktestService) Detail(ctx context.Context, u model.User, id string) (model.BacktestRun, error) {
if e := s.authorize(u); e != nil {
return model.BacktestRun{}, e
}
if _, e := uuid.Parse(id); e != nil {
return model.BacktestRun{}, apperror.ErrInvalidInput
}
return s.repo.Detail(ctx, id)
}
func (s *BacktestService) Create(ctx context.Context, u model.User, in model.BacktestInput, requestID string) (model.BacktestRun, error) {
in.DataOrigin = "manual"
in.SourceVersion = 0
in.DataReceivedAt = nil
return s.createValidated(ctx, u, in, requestID)
}
func (s *BacktestService) createValidated(ctx context.Context, u model.User, in model.BacktestInput, requestID string) (model.BacktestRun, error) {
if e := s.authorize(u); e != nil {
return model.BacktestRun{}, e
}
if _, e := uuid.Parse(in.StrategyVersionID); e != nil {
return model.BacktestRun{}, apperror.ErrInvalidInput
}
in.DatasetName = strings.TrimSpace(in.DatasetName)
cash, e := model.ParseMoney(in.InitialCash)
if e != nil || cash < 10000 || cash > 100000000000 || in.DatasetName == "" || utf8.RuneCountInString(in.DatasetName) > 120 || len(in.Symbol) != 6 || len(in.Bars) < 4 || len(in.Bars) > 1000 {
return model.BacktestRun{}, apperror.ErrInvalidInput
}
for _, r := range in.Symbol {
if r < '0' || r > '9' {
return model.BacktestRun{}, apperror.ErrInvalidInput
}
}
last := ""
for _, b := range in.Bars {
date, e := time.Parse("2006-01-02", b.Date)
if e != nil || b.Date <= last || date.After(time.Now()) {
return model.BacktestRun{}, apperror.ErrInvalidInput
}
last = b.Date
o, e1 := model.ParseMoney(b.Open)
h, e2 := model.ParseMoney(b.High)
l, e3 := model.ParseMoney(b.Low)
c, e4 := model.ParseMoney(b.Close)
if e1 != nil || e2 != nil || e3 != nil || e4 != nil || l <= 0 || h > 100000000 || l > o || l > c || h < o || h < c {
return model.BacktestRun{}, apperror.ErrInvalidInput
}
}
in.InitialCash = model.FormatMoney(cash)
return s.repo.Create(ctx, in, u.ID, requestID)
}
func (s *BacktestService) ProcessNext(ctx context.Context) (bool, error) {
job, e := s.repo.Claim(ctx)
if e == apperror.ErrNotFound {
return false, nil
}
if e != nil {
return false, e
}
result, e := s.engine.Execute(ctx, job.Input)
if ctx.Err() != nil {
return true, ctx.Err()
} // The lease recovers interrupted work after restart.
failure := ""
if e != nil {
failure = "Python 执行失败,请检查解释器安装和输入;本任务未生成研究结果。"
result = nil
}
return true, s.repo.Finish(ctx, job, result, failure)
}
func (s *BacktestService) Run(ctx context.Context, logger *zap.Logger) {
if s == nil || s.repo == nil || s.engine == nil || !s.development {
return
}
timer := time.NewTicker(2 * time.Second)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
if _, e := s.ProcessNext(ctx); e != nil && ctx.Err() == nil {
logger.Error("backtest worker failed", zap.Error(e))
}
}
}
}
func (s *BacktestService) CreateMarket(ctx context.Context, u model.User, version, symbol, exchange, start, end, cash, requestID string) (model.BacktestRun, error) {
if e := s.authorize(u); e != nil {
return model.BacktestRun{}, e
}
if s.market == nil {
return model.BacktestRun{}, apperror.ErrUnavailable
}
history, e := s.market.History(ctx, u, symbol, exchange, start, end)
if e != nil {
return model.BacktestRun{}, e
}
input := model.BacktestInput{StrategyVersionID: version, Symbol: symbol, InitialCash: cash, Bars: history.Bars, DatasetName: history.Source + " 日线 · 不复权 · " + start + " 至 " + end, DataOrigin: history.Source, SourceVersion: history.SourceVersion, DataReceivedAt: &history.ReceivedAt}
return s.createValidated(ctx, u, input, requestID)
}