feat: 自动候选筛选与股票研究工作台

This commit is contained in:
2026-10-09 20:39:23 +08:00
parent 92329ddce2
commit 57882ff568
32 changed files with 1343 additions and 87 deletions
+259
View File
@@ -0,0 +1,259 @@
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):
}
}
}
+69
View File
@@ -0,0 +1,69 @@
package service
import (
"testing"
"time"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
)
func screeningFixture() (model.ScreeningTarget, model.ProviderConfig, model.MarketHistory, time.Time) {
now := time.Date(2026, 10, 9, 9, 0, 0, 0, time.UTC) // 17:00 Shanghai
c := model.ProviderConfig{ID: "source", Version: 2, MarketAdapter: "sina_public", HistoryBaseURL: "https://quotes.example.cn"}
p := model.StrategyParameters{ShortWindow: 2, LongWindow: 3, Screening: &model.ScreeningParameters{Securities: []model.ScreeningSecurity{{Symbol: "600000", Exchange: "SSE"}}, EntryBandBPS: 100, StopLossBPS: 500, TargetBPS: 1000, ValidHours: 24}}
t := model.ScreeningTarget{StrategyID: "strategy", StrategyName: "均线研究", Version: model.StrategyVersion{ID: "version", Revision: 3, Parameters: p}, Instrument: model.Instrument{Symbol: "600000", Exchange: "SSE", Name: "fixture", SourceID: c.ID, SourceVersion: c.Version, ObservedAt: now.Add(-2 * time.Hour), ReceivedAt: now}}
h := model.MarketHistory{Symbol: "600000", Exchange: "SSE", Source: c.MarketAdapter, SourceVersion: c.Version, Adjustment: "unadjusted", ReceivedAt: now, Bars: []model.HistoricalBar{{Date: "2026-10-06", Open: "10.00", High: "10.00", Low: "10.00", Close: "10.00"}, {Date: "2026-10-07", Open: "9.00", High: "9.00", Low: "9.00", Close: "9.00"}, {Date: "2026-10-08", Open: "8.00", High: "8.00", Low: "8.00", Close: "8.00"}, {Date: "2026-10-09", Open: "20.01", High: "20.01", Low: "20.01", Close: "20.01"}}}
return t, c, h, now
}
func TestScreeningExactCompletedBarSignal(t *testing.T) {
target, c, h, now := screeningFixture()
run, draft, e := EvaluateScreening(target, c, h, now)
if e != nil || draft == nil || run.Status != "SIGNAL" || len(run.InputHash) != 64 {
t.Fatalf("signal: %v %#v", e, run)
}
plan := draft.Content.ReferencePlan
if plan.EntryLow != "19.80" || plan.EntryHigh != "20.22" || plan.StopLoss != "19.00" || plan.TargetPrice != "22.02" {
t.Fatalf("price precision: %+v", plan)
}
again, _, _ := EvaluateScreening(target, c, h, now.Add(time.Minute))
if again.InputHash != run.InputHash {
t.Fatal("hash depends on receive time")
}
h.Bars[3] = model.HistoricalBar{Date: "2026-10-09", Open: "7", High: "7", Low: "7", Close: "7"}
run, draft, e = EvaluateScreening(target, c, h, now)
if e != nil || draft != nil || run.Status != "NO_SIGNAL" {
t.Fatal("falling average manufactured signal")
}
}
func TestScreeningRejectsUnusableInputs(t *testing.T) {
for _, name := range []string{"intraday", "stale_observation", "stale_receive", "wrong_source", "missing_bar", "duplicate_bar", "invalid_ohlc", "invalid_rules", "future_receive"} {
t.Run(name, func(t *testing.T) {
target, c, h, now := screeningFixture()
switch name {
case "intraday":
now = now.Add(-2 * time.Hour)
target.Instrument.ReceivedAt = now
h.ReceivedAt = now
case "stale_observation":
target.Instrument.ObservedAt = now.Add(-25 * time.Hour)
case "stale_receive":
target.Instrument.ReceivedAt = now.Add(-11 * time.Minute)
case "wrong_source":
h.SourceVersion++
case "missing_bar":
h.Bars = h.Bars[:3]
case "duplicate_bar":
h.Bars[1].Date = h.Bars[0].Date
case "invalid_ohlc":
h.Bars[3].High = "19"
case "invalid_rules":
target.Version.Parameters.Screening.StopLossBPS = 100
case "future_receive":
h.ReceivedAt = now.Add(2 * time.Minute)
}
if _, draft, e := EvaluateScreening(target, c, h, now); e == nil || draft != nil {
t.Fatal("unsafe data generated candidate")
}
})
}
}
+3
View File
@@ -78,6 +78,9 @@ func (s *StrategyService) Save(ctx context.Context, actor model.User, id string,
if !strategyCodePattern.MatchString(input.Code) || input.Name == "" || utf8.RuneCountInString(input.Name) > 80 || input.Description == "" || utf8.RuneCountInString(input.Description) > 2000 || p.ShortWindow < 2 || p.LongWindow <= p.ShortWindow || p.LongWindow > 200 || p.FeeBPS < 0 || p.FeeBPS > 1000 || p.SlippageBPS < 0 || p.SlippageBPS > 1000 || p.LotSize < 1 || p.LotSize > 10000 {
return model.StrategyDetail{}, apperror.ErrInvalidInput
}
if err := model.ValidateScreening(p.Screening); err != nil {
return model.StrategyDetail{}, err
}
return s.repo.Save(ctx, id, input, actor.ID, requestID)
}
func (s *StrategyService) Deploy(ctx context.Context, actor model.User, id, versionID, status string, expected int64, reason, requestID string) (model.StrategyDetail, error) {