260 lines
10 KiB
Go
260 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
type ScreeningRepository interface {
|
|
Targets(context.Context, model.ProviderConfig) ([]model.ScreeningTarget, error)
|
|
Record(context.Context, model.ScreeningTarget, model.StrategyScreening, *model.CandidateDraftInput) error
|
|
List(context.Context) ([]model.StrategyScreening, error)
|
|
Detail(context.Context, string) (model.StrategyScreening, error)
|
|
}
|
|
type ScreeningService struct {
|
|
repo ScreeningRepository
|
|
market *MarketService
|
|
development bool
|
|
cursor int
|
|
mu sync.Mutex
|
|
wake chan struct{}
|
|
state model.ScreeningWorkerState
|
|
}
|
|
|
|
func NewScreeningService(repo ScreeningRepository, market *MarketService, development bool) *ScreeningService {
|
|
return &ScreeningService{repo: repo, market: market, development: development, wake: make(chan struct{}, 1), state: model.ScreeningWorkerState{State: "IDLE", Message: "尚未运行"}}
|
|
}
|
|
func (s *ScreeningService) List(ctx context.Context, actor model.User) ([]model.StrategyScreening, error) {
|
|
if actor.Role != "ADMIN" || actor.Status != "ACTIVE" {
|
|
return nil, apperror.ErrForbidden
|
|
}
|
|
if s == nil || s.repo == nil || !s.development {
|
|
return nil, apperror.ErrUnavailable
|
|
}
|
|
return s.repo.List(ctx)
|
|
}
|
|
|
|
func (s *ScreeningService) Detail(ctx context.Context, actor model.User, id string) (model.StrategyScreening, error) {
|
|
if actor.Role != "ADMIN" || actor.Status != "ACTIVE" {
|
|
return model.StrategyScreening{}, apperror.ErrForbidden
|
|
}
|
|
if s == nil || s.repo == nil || !s.development {
|
|
return model.StrategyScreening{}, apperror.ErrUnavailable
|
|
}
|
|
if _, err := uuid.Parse(id); err != nil {
|
|
return model.StrategyScreening{}, apperror.ErrInvalidInput
|
|
}
|
|
return s.repo.Detail(ctx, id)
|
|
}
|
|
|
|
func (s *ScreeningService) State(actor model.User) (model.ScreeningWorkerState, error) {
|
|
if actor.Role != "ADMIN" || actor.Status != "ACTIVE" {
|
|
return model.ScreeningWorkerState{}, apperror.ErrForbidden
|
|
}
|
|
if s == nil || s.repo == nil || s.market == nil || s.market.providers == nil || s.market.adapter == nil || !s.development {
|
|
return model.ScreeningWorkerState{}, apperror.ErrUnavailable
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.state, nil
|
|
}
|
|
func (s *ScreeningService) RequestRun(actor model.User) (model.ScreeningWorkerState, error) {
|
|
if _, err := s.State(actor); err != nil {
|
|
return model.ScreeningWorkerState{}, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.state.State == "RUNNING" || s.state.State == "QUEUED" {
|
|
return s.state, nil
|
|
}
|
|
s.state = model.ScreeningWorkerState{State: "QUEUED", Message: "已排队,等待后台开始筛选"}
|
|
select {
|
|
case s.wake <- struct{}{}:
|
|
default:
|
|
}
|
|
return s.state, nil
|
|
}
|
|
|
|
var screeningZone = time.FixedZone("Asia/Shanghai", 8*60*60)
|
|
|
|
// EvaluateScreening uses completed daily bars only. Re-fetching a historical quote
|
|
// never makes it fresh, and an unclosed session cannot generate a daily signal.
|
|
func EvaluateScreening(t model.ScreeningTarget, c model.ProviderConfig, h model.MarketHistory, now time.Time) (model.StrategyScreening, *model.CandidateDraftInput, error) {
|
|
var result model.StrategyScreening
|
|
p := t.Version.Parameters
|
|
q := p.Screening
|
|
i := t.Instrument
|
|
if q == nil || model.ValidateScreening(q) != nil || p.ShortWindow < 2 || p.LongWindow <= p.ShortWindow || p.LongWindow > 200 || h.Symbol != i.Symbol || h.Exchange != i.Exchange || h.Source != c.MarketAdapter || h.SourceVersion != c.Version || h.Adjustment != "unadjusted" || i.SourceID != c.ID || i.SourceVersion != c.Version || i.ObservedAt.Before(now.Add(-24*time.Hour)) || i.ObservedAt.After(now.Add(time.Minute)) || i.ReceivedAt.Before(now.Add(-10*time.Minute)) || h.ReceivedAt.Before(now.Add(-10*time.Minute)) || h.ReceivedAt.After(now.Add(time.Minute)) {
|
|
return result, nil, apperror.ErrUnavailable
|
|
}
|
|
day := i.ObservedAt.In(screeningZone).Format("2006-01-02")
|
|
localNow := now.In(screeningZone)
|
|
if day == localNow.Format("2006-01-02") && localNow.Hour() < 16 {
|
|
return result, nil, apperror.ErrUnavailable
|
|
}
|
|
if len(h.Bars) < p.LongWindow+1 || h.Bars[len(h.Bars)-1].Date != day {
|
|
return result, nil, apperror.ErrUnavailable
|
|
}
|
|
bars := append([]model.HistoricalBar(nil), h.Bars[len(h.Bars)-p.LongWindow-1:]...)
|
|
closes := make([]int64, len(bars))
|
|
previous := ""
|
|
for j, b := range bars {
|
|
d, e := time.Parse("2006-01-02", b.Date)
|
|
o, e1 := model.ParseMoney(b.Open)
|
|
hi, e2 := model.ParseMoney(b.High)
|
|
lo, e3 := model.ParseMoney(b.Low)
|
|
cl, e4 := model.ParseMoney(b.Close)
|
|
if e != nil || d.Format("2006-01-02") != b.Date || b.Date <= previous || b.Date > day || e1 != nil || e2 != nil || e3 != nil || e4 != nil || lo <= 0 || hi < lo || o < lo || o > hi || cl < lo || cl > hi {
|
|
return result, nil, apperror.ErrInvalidInput
|
|
}
|
|
closes[j] = cl
|
|
previous = b.Date
|
|
}
|
|
sum := func(end, n int) int64 {
|
|
var v int64
|
|
for _, x := range closes[end-n : end] {
|
|
v += x
|
|
}
|
|
return v
|
|
}
|
|
end := len(closes)
|
|
prevShort, prevLong := sum(end-1, p.ShortWindow), sum(end-1, p.LongWindow)
|
|
short, long := sum(end, p.ShortWindow), sum(end, p.LongWindow)
|
|
signal := prevShort*int64(p.LongWindow) <= prevLong*int64(p.ShortWindow) && short*int64(p.LongWindow) > long*int64(p.ShortWindow)
|
|
input, _ := json.Marshal(struct {
|
|
Parameters model.StrategyParameters `json:"parameters"`
|
|
Bars []model.HistoricalBar `json:"bars"`
|
|
}{p, bars})
|
|
hash := sha256.Sum256(input)
|
|
result = model.StrategyScreening{StrategyVersionID: t.Version.ID, Symbol: i.Symbol, Exchange: i.Exchange, BarDate: day, SourceID: c.ID, SourceVersion: c.Version, SourceAdapter: c.MarketAdapter, ObservedAt: i.ObservedAt, ReceivedAt: h.ReceivedAt, InputHash: hex.EncodeToString(hash[:]), Input: input, Status: "NO_SIGNAL"}
|
|
if !signal {
|
|
return result, nil, nil
|
|
}
|
|
base := closes[end-1]
|
|
low := base * (10000 - q.EntryBandBPS) / 10000
|
|
high := (base*(10000+q.EntryBandBPS) + 9999) / 10000
|
|
stop := base * (10000 - q.StopLossBPS) / 10000
|
|
target := (base*(10000+q.TargetBPS) + 9999) / 10000
|
|
draft := model.CandidateDraftInput{ObservedAt: i.ObservedAt, ValidUntil: now.Add(time.Duration(q.ValidHours) * time.Hour), Content: model.CandidateContent{
|
|
Symbol: i.Symbol, Exchange: i.Exchange, Name: i.Name, StrategyCode: "trend_following", Title: i.Name + " · 日线均线交叉待审核",
|
|
Thesis: fmt.Sprintf("已完成日线 %s 出现 %d / %d 日均线上穿。策略 %s 修订 %d,仅为规则筛选结果,需人工核验后发布。", day, p.ShortWindow, p.LongWindow, t.StrategyName, t.Version.Revision),
|
|
Evidence: []model.CandidateEvidence{{SourceURL: c.HistoryBaseURL, Locator: fmt.Sprintf("%s:%s 日线 %s", i.Exchange, i.Symbol, day), Summary: fmt.Sprintf("来源 %s 配置版本 %d;未复权收盘价 %s;输入摘要 %s。参数与日线快照在筛选记录中保留。", c.MarketAdapter, c.Version, model.FormatMoney(base), result.InputHash)}},
|
|
CounterEvidence: "公开数据可能缺失或延迟;未复权均线会受除权影响。此规则未核验公告、停复牌、涨跌停、流动性及策略有效性。",
|
|
Invalidation: "策略暂停或切换版本、候选过期或撤回后失效;复核价格区间及数据来源后方可发布。",
|
|
ReferencePlan: model.CandidateReferencePlan{EntryLow: model.FormatMoney(low), EntryHigh: model.FormatMoney(high), StopLoss: model.FormatMoney(stop), TargetPrice: model.FormatMoney(target)},
|
|
}}
|
|
if e := model.ValidateDraft(draft, now); e != nil {
|
|
return result, nil, e
|
|
}
|
|
result.Status = "SIGNAL"
|
|
return result, &draft, nil
|
|
}
|
|
func (s *ScreeningService) Process(ctx context.Context, logger *zap.Logger) (processErr error) {
|
|
started := time.Now().UTC()
|
|
s.mu.Lock()
|
|
if s.state.State == "RUNNING" {
|
|
s.mu.Unlock()
|
|
return apperror.ErrConflict
|
|
}
|
|
s.state = model.ScreeningWorkerState{State: "RUNNING", StartedAt: &started, Message: "正在获取日线并计算信号"}
|
|
s.mu.Unlock()
|
|
defer func() {
|
|
finished := time.Now().UTC()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.state.FinishedAt = &finished
|
|
if processErr != nil {
|
|
s.state.State = "FAILED"
|
|
s.state.Message = "筛选未完成,请检查行情源与后台日志"
|
|
} else {
|
|
s.state.State = "COMPLETED"
|
|
s.state.Message = fmt.Sprintf("检查 %d 只,完成 %d 只,生成 %d 条待审核候选,跳过 %d 只,失败 %d 只", s.state.Targets, s.state.Completed, s.state.Generated, s.state.Skipped, s.state.Failed)
|
|
}
|
|
}()
|
|
|
|
c, err := s.market.configuration(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
targets, err := s.repo.Targets(ctx, c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Rotate across eligible targets so an unavailable feed cannot starve other strategies.
|
|
if len(targets) == 0 {
|
|
return nil
|
|
}
|
|
s.mu.Lock()
|
|
start := s.cursor % len(targets)
|
|
count := min(20, len(targets))
|
|
s.state.Targets = count
|
|
s.cursor = (start + count) % len(targets)
|
|
s.mu.Unlock()
|
|
for offset := 0; offset < count; offset++ {
|
|
t := targets[(start+offset)%len(targets)]
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
now := time.Now().UTC()
|
|
task, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
h, e := s.market.adapter.History(task, c, t.Instrument.Symbol, t.Instrument.Exchange, now.AddDate(-3, 0, 0).Format("2006-01-02"), now.Format("2006-01-02"))
|
|
if e == nil {
|
|
var run model.StrategyScreening
|
|
var draft *model.CandidateDraftInput
|
|
run, draft, e = EvaluateScreening(t, c, h, time.Now().UTC())
|
|
if e == nil {
|
|
e = s.repo.Record(task, t, run, draft)
|
|
if e == nil {
|
|
s.mu.Lock()
|
|
s.state.Completed++
|
|
if draft != nil {
|
|
s.state.Generated++
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
cancel()
|
|
if e != nil {
|
|
s.mu.Lock()
|
|
if errors.Is(e, apperror.ErrConflict) || errors.Is(e, apperror.ErrUnavailable) {
|
|
s.state.Skipped++
|
|
} else {
|
|
s.state.Failed++
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
if e != nil && !errors.Is(e, apperror.ErrConflict) && !errors.Is(e, apperror.ErrUnavailable) && ctx.Err() == nil {
|
|
logger.Warn("strategy screening failed", zap.String("strategy_version_id", t.Version.ID), zap.String("symbol", t.Instrument.Symbol), zap.Error(e))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func (s *ScreeningService) Run(ctx context.Context, logger *zap.Logger) {
|
|
if s == nil || s.repo == nil || s.market == nil || s.market.adapter == nil || !s.development {
|
|
return
|
|
}
|
|
for {
|
|
if err := s.Process(ctx, logger); err != nil && !errors.Is(err, apperror.ErrUnavailable) && !errors.Is(err, apperror.ErrNotFound) && ctx.Err() == nil {
|
|
logger.Warn("strategy screening worker failed", zap.Error(err))
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-s.wake:
|
|
case <-time.After(5 * time.Minute):
|
|
}
|
|
}
|
|
}
|