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
@@ -0,0 +1,21 @@
CREATE TABLE veyra_strategy_screening (
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
strategy_version_id varchar(36) NOT NULL REFERENCES veyra_strategy_version(id),
symbol varchar(6) NOT NULL CHECK(symbol ~ '^[0-9]{6}$'),
exchange varchar(4) NOT NULL CHECK(exchange IN ('SSE','SZSE')),
bar_date date NOT NULL,
source_id varchar(36) NOT NULL REFERENCES veyra_provider_config(id),
source_version bigint NOT NULL CHECK(source_version>0),
source_adapter varchar(32) NOT NULL CHECK(source_adapter IN ('sina_public','eastmoney_public')),
observed_at timestamptz NOT NULL,
received_at timestamptz NOT NULL,
input_hash varchar(64) NOT NULL CHECK(input_hash ~ '^[0-9a-f]{64}$'),
input jsonb NOT NULL,
status varchar(16) NOT NULL CHECK(status IN ('SIGNAL','NO_SIGNAL')),
candidate_revision_id varchar(36) UNIQUE REFERENCES veyra_candidate_revision(id),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(strategy_version_id,exchange,symbol,bar_date,source_id,source_version),
CHECK ((status='SIGNAL')=(candidate_revision_id IS NOT NULL))
);
CREATE INDEX veyra_strategy_screening_created_idx ON veyra_strategy_screening(created_at DESC,id DESC);
CREATE TRIGGER veyra_strategy_screening_immutable BEFORE UPDATE OR DELETE ON veyra_strategy_screening FOR EACH ROW EXECUTE FUNCTION veyra_reject_fact_mutation();
@@ -0,0 +1,5 @@
ALTER TABLE veyra_quote_snapshot DROP CONSTRAINT veyra_quote_snapshot_data_mode_check;
ALTER TABLE veyra_quote_snapshot ADD CONSTRAINT veyra_quote_snapshot_data_mode_check CHECK(data_mode IN ('simulation','public'));
ALTER TABLE veyra_quote_snapshot ADD COLUMN source_id varchar(36) REFERENCES veyra_provider_config(id);
ALTER TABLE veyra_quote_snapshot ADD COLUMN source_version bigint;
ALTER TABLE veyra_quote_snapshot ADD CONSTRAINT veyra_quote_snapshot_source_check CHECK((data_mode='simulation' AND source_id IS NULL AND source_version IS NULL) OR (data_mode='public' AND source_id IS NOT NULL AND source_version IS NOT NULL AND source_version>0));
+1
View File
@@ -21,6 +21,7 @@ type LoginLimiter interface {
Login(context.Context, string, string) (bool, error)
}
type IdentityOptions struct {
Screenings *service.ScreeningService
Market *service.MarketService
Backtests *service.BacktestService
Auth *service.AuthService
+52
View File
@@ -16,6 +16,42 @@ func bindStrategies(r *gin.Engine, options IdentityOptions) {
}
c.Next()
})
group.GET("/strategy-screenings", func(c *gin.Context) {
if options.Screenings == nil {
fail(c, apperror.ErrUnavailable)
return
}
result, err := options.Screenings.List(c.Request.Context(), currentUser(c))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.GET("/strategy-screenings/:id", func(c *gin.Context) {
if options.Screenings == nil {
fail(c, apperror.ErrUnavailable)
return
}
result, err := options.Screenings.Detail(c.Request.Context(), currentUser(c), c.Param("id"))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.GET("/strategy-screening-status", func(c *gin.Context) {
if options.Screenings == nil {
fail(c, apperror.ErrUnavailable)
return
}
result, err := options.Screenings.State(currentUser(c))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.GET("/strategies", func(c *gin.Context) {
result, err := options.Strategies.List(c.Request.Context(), currentUser(c))
if err != nil {
@@ -39,6 +75,22 @@ func bindStrategies(r *gin.Engine, options IdentityOptions) {
}
c.Next()
})
group.POST("/strategy-screenings/run", func(c *gin.Context) {
if options.Screenings == nil {
fail(c, apperror.ErrUnavailable)
return
}
var input struct{}
if !decode(c, &input) {
return
}
result, err := options.Screenings.RequestRun(currentUser(c))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusAccepted, result)
})
for _, path := range []string{"/strategies", "/strategies/:id/versions"} {
group.POST(path, func(c *gin.Context) {
var input service.StrategyInput
@@ -197,6 +197,7 @@ func TestIdentityAccountLifecycle(t *testing.T) {
}
testCandidateLifecycle(t, ctx, pool, auth, adminUser, u1)
testStrategyBacktest(t, ctx, pool, adminUser, u1)
testScreeningLifecycle(t, ctx, pool, adminUser, u1)
testTradeLedger(t, ctx, pool, auth)
testAdminCredentialChange(t, ctx, pool, auth)
}
+297
View File
@@ -0,0 +1,297 @@
package integration
import (
"context"
"encoding/json"
"errors"
"go.uber.org/zap"
"strings"
"sync"
"testing"
"time"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
"git.sundynix.cn/Blizzard/Veyra/internal/service"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func testScreeningLifecycle(t *testing.T, ctx context.Context, pool *pgxpool.Pool, admin, user model.User) {
strategies := service.NewStrategyService(repository.NewStrategyRepository(pool), true)
input := service.StrategyInput{Code: "auto_screening", Name: "自动筛选测试", Description: "integration fixture", Parameters: model.StrategyParameters{ShortWindow: 2, LongWindow: 3, LotSize: 100, Screening: &model.ScreeningParameters{Securities: []model.ScreeningSecurity{{Symbol: "600000", Exchange: "SSE"}}, EntryBandBPS: 100, StopLossBPS: 500, TargetBPS: 1000, ValidHours: 24}}}
st, err := strategies.Save(ctx, admin, "", input, uuid.NewString())
if err != nil {
t.Fatal(err)
}
st, err = strategies.Deploy(ctx, admin, st.ID, st.Versions[0].ID, "ACTIVE", st.Version, "fixture activation", uuid.NewString())
if err != nil {
t.Fatal(err)
}
providerRepo := repository.NewProviderRepository(pool)
provider, err := providerRepo.Get(ctx, "market_primary")
if err != nil {
t.Fatal(err)
}
// Only disposable-schema fixtures are modified, never the development configuration.
if _, err := pool.Exec(ctx, `UPDATE veyra_provider_config SET enabled=true,market_adapter='sina_public',history_base_url='https://quotes.example.cn' WHERE id=$1`, provider.ID); err != nil {
t.Fatal(err)
}
provider, err = providerRepo.Get(ctx, "market_primary")
if err != nil {
t.Fatal(err)
}
observed := time.Now().UTC().Add(-23 * time.Hour)
instrument := model.Instrument{Symbol: "600000", Exchange: "SSE", Name: "fixture", PriceCents: 2001, ObservedAt: observed}
if err := repository.NewMarketRepository(pool).Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil {
t.Fatal(err)
}
repo := repository.NewScreeningRepository(pool)
targets, err := repo.Targets(ctx, provider)
if err != nil || len(targets) != 1 {
t.Fatalf("targets %d: %v", len(targets), err)
}
target := targets[0]
zone := time.FixedZone("Shanghai", 8*3600)
run := model.StrategyScreening{StrategyVersionID: target.Version.ID, Symbol: "600000", Exchange: "SSE", BarDate: observed.In(zone).Format("2006-01-02"), SourceID: provider.ID, SourceVersion: provider.Version, SourceAdapter: "sina_public", ObservedAt: observed, ReceivedAt: time.Now().UTC(), InputHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", Input: json.RawMessage(`{"fixture":true}`), Status: "SIGNAL"}
draft := model.CandidateDraftInput{ObservedAt: observed, ValidUntil: time.Now().UTC().Add(time.Hour), Content: model.CandidateContent{Symbol: "600000", Exchange: "SSE", Name: "fixture", StrategyCode: "trend_following", Title: "fixture signal", Thesis: "fixture", CounterEvidence: "fixture risk", Invalidation: "fixture pause", Evidence: []model.CandidateEvidence{{SourceURL: "https://quotes.example.cn", Locator: "fixture", Summary: "fixture"}}, ReferencePlan: model.CandidateReferencePlan{EntryLow: "19.80", EntryHigh: "20.22", StopLoss: "19.00", TargetPrice: "22.02"}}}
var before int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_candidate`).Scan(&before); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
results := make(chan error, 2)
for range 2 {
wg.Add(1)
go func() { defer wg.Done(); results <- repo.Record(ctx, target, run, &draft) }()
}
wg.Wait()
close(results)
success, conflict := 0, 0
for e := range results {
if e == nil {
success++
} else if errors.Is(e, apperror.ErrConflict) {
conflict++
} else {
t.Fatal(e)
}
}
if success != 1 || conflict != 1 {
t.Fatal("concurrent duplicate screening did not converge")
}
var after int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_candidate`).Scan(&after); err != nil || after != before+1 {
t.Fatal("duplicate screening left orphan candidate")
}
records, err := repo.List(ctx)
if err != nil || len(records) != 1 || records[0].CandidateRevisionID == nil {
t.Fatal("screening provenance missing")
}
if _, err := pool.Exec(ctx, `UPDATE veyra_strategy_screening SET status='NO_SIGNAL' WHERE id=$1`, records[0].ID); err == nil {
t.Fatal("screening evidence mutable")
}
recordDetail, err := repo.Detail(ctx, records[0].ID)
if err != nil || string(recordDetail.Input) != "{\"fixture\": true}" {
t.Fatalf("immutable input detail: %s %v", recordDetail.Input, err)
}
candidates := service.NewCandidateService(repository.NewCandidateRepository(pool), true)
candidate, err := candidates.Detail(ctx, admin, *records[0].CandidateRevisionID, true)
if err != nil || candidate.Status != "PENDING_REVIEW" || candidate.Screening == nil {
t.Fatalf("review queue provenance: %v", err)
}
if _, err := candidates.Detail(ctx, user, candidate.ID, false); !errors.Is(err, apperror.ErrNotFound) {
t.Fatal("unreviewed signal leaked to App")
}
pub, err := candidates.Transition(ctx, admin, candidate.ID, "PUBLISH", uuid.NewString(), model.CandidateActionInput{ExpectedVersion: candidate.Version, Reason: "fixture review"})
if err != nil || !pub.IsActivePublication {
t.Fatalf("review publish: %v", err)
}
accounts := service.NewAccountService(repository.NewAccountRepository(pool))
cash := "100000.00"
auth, err := service.NewAuthService(repository.NewIdentityRepository(pool))
if err != nil {
t.Fatal(err)
}
owner, err := auth.ProvisionUser(ctx, "scanner_user", "a-test-only-password", "Scanner User", "USER")
if err != nil {
t.Fatal(err)
}
account, err := accounts.Create(ctx, owner.ID, "simulation", "scanner fixture", &cash)
if err != nil {
t.Fatal(err)
}
plans := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true)
preview, err := plans.Preview(ctx, owner, service.PlanPreviewInput{AccountID: account.ID, CandidateRevisionID: pub.ID, ExpectedCandidateVersion: pub.Version, Quantity: "100", FeeBudget: "0"})
if err != nil {
t.Fatal(err)
}
plan, err := plans.Confirm(ctx, owner, preview.ID, uuid.NewString())
if err != nil {
t.Fatal(err)
}
// Real public quote ingestion uses the existing outbox and never writes ledger facts.
if err := repository.NewMarketRepository(pool).Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil {
t.Fatal(err)
}
var publicCount int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_quote_snapshot WHERE data_mode='public'`).Scan(&publicCount); err != nil || publicCount != 0 {
t.Fatal("old market close queued a public alert")
}
instrument.ObservedAt = time.Now().UTC()
marketRepo := repository.NewMarketRepository(pool)
if err := marketRepo.Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil {
t.Fatal(err)
}
notifications := repository.NewNotificationRepository(pool)
for range 100 {
worked, err := notifications.ProcessNext(ctx)
if err != nil {
t.Fatal(err)
}
if !worked {
break
}
}
notices, err := notifications.List(ctx, owner.ID)
if err != nil || len(notices) != 1 {
t.Fatalf("public alert missing: %v count %d", err, len(notices))
}
if !strings.Contains(notices[0].Body, "公开行情价格") {
t.Fatal("public price mislabeled as manual simulation")
}
if err := marketRepo.Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil {
t.Fatal(err)
}
for range 100 {
worked, err := notifications.ProcessNext(ctx)
if err != nil {
t.Fatal(err)
}
if !worked {
break
}
}
notices, err = notifications.List(ctx, owner.ID)
if err != nil || len(notices) != 1 {
t.Fatal("unchanged quote duplicated reminder")
}
var balance int64
var fills int
if err := pool.QueryRow(ctx, `SELECT sum(amount_minor)::bigint FROM veyra_cash_entry WHERE account_id=$1`, account.ID).Scan(&balance); err != nil || balance != 10000000 {
t.Fatal("public alert altered cash")
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_trade_fill WHERE account_id=$1`, account.ID).Scan(&fills); err != nil || fills != 0 {
t.Fatal("public alert posted trade")
}
// A queued observation from a disabled source must not trigger another plan.
preview2, err := plans.Preview(ctx, owner, service.PlanPreviewInput{AccountID: account.ID, CandidateRevisionID: pub.ID, ExpectedCandidateVersion: pub.Version, Quantity: "100", FeeBudget: "0"})
if err != nil {
t.Fatal(err)
}
if _, err := plans.Confirm(ctx, owner, preview2.ID, uuid.NewString()); err != nil {
t.Fatal(err)
}
instrument.ObservedAt = time.Now().UTC()
if err := marketRepo.Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `UPDATE veyra_provider_config SET enabled=false WHERE id=$1`, provider.ID); err != nil {
t.Fatal(err)
}
for range 100 {
worked, err := notifications.ProcessNext(ctx)
if err != nil {
t.Fatal(err)
}
if !worked {
break
}
}
notices, err = notifications.List(ctx, owner.ID)
if err != nil || len(notices) != 1 {
t.Fatal("disabled source generated delayed reminder")
}
if _, err := pool.Exec(ctx, `UPDATE veyra_provider_config SET enabled=true WHERE id=$1`, provider.ID); err != nil {
t.Fatal(err)
}
st, err = strategies.Deploy(ctx, admin, st.ID, "", "PAUSED", st.Version, "fixture pause", uuid.NewString())
if err != nil {
t.Fatal(err)
}
detail, err := candidates.Detail(ctx, user, pub.ID, false)
if err != nil || detail.IsActivePublication {
t.Fatal("paused signal stayed actionable")
}
if _, err := plans.Preview(ctx, owner, service.PlanPreviewInput{AccountID: account.ID, CandidateRevisionID: pub.ID, ExpectedCandidateVersion: pub.Version, Quantity: "100", FeeBudget: "0"}); !errors.Is(err, apperror.ErrCandidateUnavailable) {
t.Fatal("paused strategy accepted plan")
}
list, err := plans.List(ctx, owner)
if err != nil || len(list) != 2 {
t.Fatalf("pause did not release plan: %v", err)
}
found := false
for _, p := range list {
if p.ID == plan.ID {
found = true
}
if p.Status != "INVALIDATED" || p.ReservedMinor != 0 {
t.Fatal("paused plan kept reserve")
}
}
if !found {
t.Fatal("original plan disappeared")
}
run.Symbol = "600001"
if err := repo.Record(ctx, target, run, &draft); !errors.Is(err, apperror.ErrConflict) {
t.Fatal("paused deployment accepted late worker")
}
worker := service.NewScreeningService(repo, service.NewMarketService(repository.NewMarketRepository(pool), providerRepo, idleScreeningAdapter{}, true), true)
if _, err := worker.RequestRun(user); !errors.Is(err, apperror.ErrForbidden) {
t.Fatal("app user requested research run")
}
first, err := worker.RequestRun(admin)
if err != nil || first.State != "QUEUED" {
t.Fatal("run did not queue")
}
second, err := worker.RequestRun(admin)
if err != nil || second.State != "QUEUED" {
t.Fatal("duplicate queue request changed state")
}
workerCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
go func() { defer close(done); worker.Run(workerCtx, zap.NewNop()) }()
deadline := time.After(3 * time.Second)
for {
state, err := worker.State(admin)
if err != nil {
cancel()
<-done
t.Fatal(err)
}
if state.State == "COMPLETED" && state.FinishedAt != nil {
break
}
select {
case <-deadline:
cancel()
<-done
t.Fatal("queued worker did not complete")
case <-time.After(10 * time.Millisecond):
}
}
cancel()
<-done
}
// There are no eligible targets after pause, so this adapter must never be called.
type idleScreeningAdapter struct{}
func (idleScreeningAdapter) Snapshot(context.Context, model.ProviderConfig) ([]model.Instrument, error) {
return nil, apperror.ErrUnavailable
}
func (idleScreeningAdapter) History(context.Context, model.ProviderConfig, string, string, string, string) (model.MarketHistory, error) {
return model.MarketHistory{}, apperror.ErrUnavailable
}
+17 -16
View File
@@ -45,22 +45,23 @@ type CandidateContent struct {
}
type CandidateRevision struct {
ID string `db:"id" json:"id"`
CandidateID string `db:"candidate_id" json:"candidate_id"`
Revision int `db:"revision" json:"revision"`
Version int64 `db:"version" json:"version"`
Status string `db:"status" json:"status"`
DataMode string `db:"data_mode" json:"data_mode"`
Snapshot CandidateContent `db:"snapshot" json:"content"`
AuthorID string `db:"author_id" json:"author_id"`
ObservedAt time.Time `db:"observed_at" json:"observed_at"`
ValidUntil time.Time `db:"valid_until" json:"valid_until"`
PublishedAt *time.Time `db:"published_at" json:"published_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
IsLatest bool `json:"is_latest"`
WithdrawalReason string `json:"withdrawal_reason"`
IsActivePublication bool `json:"is_active_publication"`
Screening *StrategyScreening `json:"screening,omitempty"`
ID string `db:"id" json:"id"`
CandidateID string `db:"candidate_id" json:"candidate_id"`
Revision int `db:"revision" json:"revision"`
Version int64 `db:"version" json:"version"`
Status string `db:"status" json:"status"`
DataMode string `db:"data_mode" json:"data_mode"`
Snapshot CandidateContent `db:"snapshot" json:"content"`
AuthorID string `db:"author_id" json:"author_id"`
ObservedAt time.Time `db:"observed_at" json:"observed_at"`
ValidUntil time.Time `db:"valid_until" json:"valid_until"`
PublishedAt *time.Time `db:"published_at" json:"published_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
IsLatest bool `json:"is_latest"`
WithdrawalReason string `json:"withdrawal_reason"`
IsActivePublication bool `json:"is_active_publication"`
}
func (CandidateRevision) TableName() string { return "veyra_candidate_revision" }
+12 -10
View File
@@ -16,16 +16,18 @@ type Notification struct {
func (Notification) TableName() string { return "veyra_notification" }
type QuoteSnapshot struct {
ID string `db:"id" json:"id"`
Symbol string `db:"symbol" json:"symbol"`
Exchange string `db:"exchange" json:"exchange"`
DataMode string `db:"data_mode" json:"data_mode"`
PriceMinor int64 `db:"price_minor" json:"-"`
Price string `json:"price"`
ObservedAt time.Time `db:"observed_at" json:"observed_at"`
Version int64 `db:"version" json:"version"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
SourceID *string `db:"source_id" json:"source_id,omitempty"`
SourceVersion *int64 `db:"source_version" json:"source_version,omitempty"`
ID string `db:"id" json:"id"`
Symbol string `db:"symbol" json:"symbol"`
Exchange string `db:"exchange" json:"exchange"`
DataMode string `db:"data_mode" json:"data_mode"`
PriceMinor int64 `db:"price_minor" json:"-"`
Price string `json:"price"`
ObservedAt time.Time `db:"observed_at" json:"observed_at"`
Version int64 `db:"version" json:"version"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (QuoteSnapshot) TableName() string { return "veyra_quote_snapshot" }
+79
View File
@@ -0,0 +1,79 @@
package model
import (
"encoding/json"
"time"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
)
// Screening rules belong to an immutable strategy version, and are opt-in.
type ScreeningSecurity struct {
Symbol string `json:"symbol"`
Exchange string `json:"exchange"`
}
type ScreeningParameters struct {
Securities []ScreeningSecurity `json:"securities"`
EntryBandBPS int64 `json:"entry_band_bps"`
StopLossBPS int64 `json:"stop_loss_bps"`
TargetBPS int64 `json:"target_bps"`
ValidHours int `json:"valid_hours"`
}
func ValidateScreening(p *ScreeningParameters) error {
if p == nil {
return nil
}
if len(p.Securities) < 1 || len(p.Securities) > 100 || p.EntryBandBPS < 0 || p.EntryBandBPS > 1000 || p.StopLossBPS <= p.EntryBandBPS || p.StopLossBPS > 5000 || p.TargetBPS <= p.EntryBandBPS || p.TargetBPS > 10000 || p.ValidHours < 1 || p.ValidHours > 168 {
return apperror.ErrInvalidInput
}
seen := map[ScreeningSecurity]bool{}
for _, v := range p.Securities {
if !symbolPattern.MatchString(v.Symbol) || (v.Exchange != "SSE" && v.Exchange != "SZSE") || seen[v] {
return apperror.ErrInvalidInput
}
seen[v] = true
}
return nil
}
type ScreeningTarget struct {
StrategyID string
StrategyName string
Version StrategyVersion
Instrument Instrument
}
type StrategyScreening struct {
ID string `db:"id" json:"id"`
StrategyVersionID string `db:"strategy_version_id" json:"strategy_version_id"`
StrategyName string `json:"strategy_name"`
StrategyRevision int `json:"strategy_revision"`
Symbol string `db:"symbol" json:"symbol"`
Exchange string `db:"exchange" json:"exchange"`
BarDate string `db:"bar_date" json:"bar_date"`
SourceID string `db:"source_id" json:"source_id"`
SourceVersion int64 `db:"source_version" json:"source_version"`
SourceAdapter string `db:"source_adapter" json:"source_adapter"`
ObservedAt time.Time `db:"observed_at" json:"observed_at"`
ReceivedAt time.Time `db:"received_at" json:"received_at"`
InputHash string `db:"input_hash" json:"input_hash"`
Input json.RawMessage `db:"input" json:"input,omitempty"`
Status string `db:"status" json:"status"`
CandidateRevisionID *string `db:"candidate_revision_id" json:"candidate_revision_id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
func (StrategyScreening) TableName() string { return "veyra_strategy_screening" }
// Worker state is process-local; durable results remain in StrategyScreening.
type ScreeningWorkerState struct {
State string `json:"state"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
Targets int `json:"targets"`
Completed int `json:"completed"`
Generated int `json:"generated"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Message string `json:"message"`
}
+6 -5
View File
@@ -3,11 +3,12 @@ package model
import "time"
type StrategyParameters struct {
ShortWindow int `json:"short_window"`
LongWindow int `json:"long_window"`
FeeBPS int `json:"fee_bps"`
SlippageBPS int `json:"slippage_bps"`
LotSize int64 `json:"lot_size"`
Screening *ScreeningParameters `json:"screening,omitempty"`
ShortWindow int `json:"short_window"`
LongWindow int `json:"long_window"`
FeeBPS int `json:"fee_bps"`
SlippageBPS int `json:"slippage_bps"`
LotSize int64 `json:"lot_size"`
}
type Strategy struct {
ID string `db:"id" json:"id"`
+45 -5
View File
@@ -19,25 +19,33 @@ func NewCandidateRepository(pool *pgxpool.Pool) *CandidateRepository {
return &CandidateRepository{pool: pool}
}
const candidateColumns = `r.id,r.candidate_id,r.revision,r.version,r.status,r.data_mode,r.snapshot,r.author_id,r.observed_at,r.valid_until,r.published_at,r.created_at,r.updated_at,(r.revision=c.latest_revision),COALESCE(c.published_revision_id=r.id AND r.status='PUBLISHED' AND r.valid_until>now(),false),COALESCE((SELECT reason FROM veyra_candidate_review v WHERE v.revision_id=r.id AND v.decision='WITHDRAW' ORDER BY v.input_version DESC LIMIT 1),'')`
// All successors of an automatically generated candidate retain the deployment binding.
const screeningEligibility = `NOT EXISTS(SELECT 1 FROM veyra_strategy_screening x JOIN veyra_candidate_revision original ON original.id=x.candidate_revision_id JOIN veyra_strategy_version sv ON sv.id=x.strategy_version_id JOIN veyra_strategy s ON s.id=sv.strategy_id WHERE original.candidate_id=c.id AND (s.status<>'ACTIVE' OR s.active_version_id IS DISTINCT FROM sv.id))`
const candidateColumns = `r.id,r.candidate_id,r.revision,r.version,r.status,r.data_mode,r.snapshot,r.author_id,r.observed_at,r.valid_until,r.published_at,r.created_at,r.updated_at,(r.revision=c.latest_revision),COALESCE(c.published_revision_id=r.id AND r.status='PUBLISHED' AND r.valid_until>now() AND ` + screeningEligibility + `,false),COALESCE((SELECT reason FROM veyra_candidate_review v WHERE v.revision_id=r.id AND v.decision='WITHDRAW' ORDER BY v.input_version DESC LIMIT 1),''),(SELECT jsonb_build_object('id',x.id,'strategy_version_id',x.strategy_version_id,'strategy_name',s.name,'strategy_revision',sv.revision,'symbol',x.symbol,'exchange',x.exchange,'bar_date',x.bar_date::text,'source_id',x.source_id,'source_version',x.source_version,'source_adapter',x.source_adapter,'observed_at',x.observed_at,'received_at',x.received_at,'input_hash',x.input_hash,'status',x.status,'candidate_revision_id',x.candidate_revision_id,'created_at',x.created_at) FROM veyra_strategy_screening x JOIN veyra_candidate_revision original ON original.id=x.candidate_revision_id JOIN veyra_strategy_version sv ON sv.id=x.strategy_version_id JOIN veyra_strategy s ON s.id=sv.strategy_id WHERE original.candidate_id=c.id LIMIT 1)`
func scanCandidate(row pgx.Row) (model.CandidateRevision, error) {
var r model.CandidateRevision
var snapshot []byte
err := row.Scan(&r.ID, &r.CandidateID, &r.Revision, &r.Version, &r.Status, &r.DataMode, &snapshot, &r.AuthorID, &r.ObservedAt, &r.ValidUntil, &r.PublishedAt, &r.CreatedAt, &r.UpdatedAt, &r.IsLatest, &r.IsActivePublication, &r.WithdrawalReason)
var snapshot, screening []byte
err := row.Scan(&r.ID, &r.CandidateID, &r.Revision, &r.Version, &r.Status, &r.DataMode, &snapshot, &r.AuthorID, &r.ObservedAt, &r.ValidUntil, &r.PublishedAt, &r.CreatedAt, &r.UpdatedAt, &r.IsLatest, &r.IsActivePublication, &r.WithdrawalReason, &screening)
if err != nil {
return r, mapError(err)
}
if err := json.Unmarshal(snapshot, &r.Snapshot); err != nil {
return r, apperror.ErrUnavailable
}
if len(screening) > 0 {
if err := json.Unmarshal(screening, &r.Screening); err != nil {
return r, apperror.ErrUnavailable
}
}
return r, nil
}
const candidateJoin = ` FROM veyra_candidate_revision r JOIN veyra_candidate c ON c.id=r.candidate_id `
func (r *CandidateRepository) List(ctx context.Context, admin bool, status string, limit int) ([]model.CandidateRevision, error) {
query := `SELECT ` + candidateColumns + candidateJoin + ` WHERE ($1 AND (r.revision=c.latest_revision OR c.published_revision_id=r.id) AND ($2='' OR r.status=$2)) OR (NOT $1 AND c.published_revision_id=r.id AND r.status='PUBLISHED' AND r.valid_until>now()) ORDER BY r.created_at DESC,r.id DESC LIMIT $3`
query := `SELECT ` + candidateColumns + candidateJoin + ` WHERE ($1 AND (r.revision=c.latest_revision OR c.published_revision_id=r.id) AND ($2='' OR r.status=$2)) OR (NOT $1 AND c.published_revision_id=r.id AND r.status='PUBLISHED' AND r.valid_until>now() AND ` + screeningEligibility + `) ORDER BY r.created_at DESC,r.id DESC LIMIT $3`
rows, err := r.pool.Query(ctx, query, admin, status, limit)
if err != nil {
return nil, mapError(err)
@@ -84,6 +92,22 @@ func lockCandidate(ctx context.Context, tx pgx.Tx, source string) (model.Candida
if err := tx.QueryRow(ctx, `SELECT candidate_id FROM veyra_candidate_revision WHERE id=$1`, source).Scan(&parent); err != nil {
return model.CandidateRevision{}, mapError(err)
}
// Match scanner lock order: deployment, then candidate aggregate.
rows, err := tx.Query(ctx, `SELECT s.id FROM veyra_strategy s JOIN veyra_strategy_version v ON v.strategy_id=s.id JOIN veyra_strategy_screening x ON x.strategy_version_id=v.id JOIN veyra_candidate_revision original ON original.id=x.candidate_revision_id WHERE original.candidate_id=$1 ORDER BY s.id FOR SHARE OF s`, parent)
if err != nil {
return model.CandidateRevision{}, mapError(err)
}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return model.CandidateRevision{}, mapError(err)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return model.CandidateRevision{}, mapError(err)
}
var locked string
if err := tx.QueryRow(ctx, `SELECT id FROM veyra_candidate WHERE id=$1 FOR UPDATE`, parent).Scan(&locked); err != nil {
return model.CandidateRevision{}, mapError(err)
@@ -96,6 +120,15 @@ func (r *CandidateRepository) Create(ctx context.Context, input model.CandidateD
return model.CandidateRevision{}, mapError(err)
}
defer tx.Rollback(context.Background())
created, err := createCandidate(ctx, tx, input, actor, requestID, source, expected)
if err != nil {
return created, err
}
return created, mapError(tx.Commit(ctx))
}
func createCandidate(ctx context.Context, tx pgx.Tx, input model.CandidateDraftInput, actor, requestID, source string, expected int64) (model.CandidateRevision, error) {
var err error
parent, revision := uuid.NewString(), 1
if source == "" {
_, err = tx.Exec(ctx, `INSERT INTO veyra_candidate(id,latest_revision) VALUES($1,1)`, parent)
@@ -129,7 +162,7 @@ func (r *CandidateRepository) Create(ctx context.Context, input model.CandidateD
if err != nil {
return created, err
}
return created, mapError(tx.Commit(ctx))
return created, nil
}
func appendCandidateEvent(ctx context.Context, tx pgx.Tx, r model.CandidateRevision, eventType string, details any) error {
payload, err := json.Marshal(details)
@@ -157,6 +190,13 @@ func (r *CandidateRepository) Transition(ctx context.Context, id, action string,
return revision, err
}
if action == "PUBLISH" {
var eligible bool
if err := tx.QueryRow(ctx, `SELECT `+screeningEligibility+candidateJoin+` WHERE r.id=$1`, id).Scan(&eligible); err != nil {
return revision, mapError(err)
}
if !eligible {
return revision, apperror.ErrCandidateUnavailable
}
// Superseded publications keep their historical content, with an invalidation event.
previous, err := scanCandidate(tx.QueryRow(ctx, `SELECT `+candidateColumns+candidateJoin+` WHERE c.id=$1 AND c.published_revision_id=r.id`, revision.CandidateID))
if err != nil && err != apperror.ErrNotFound {
+36
View File
@@ -2,6 +2,7 @@ package repository
import (
"context"
"encoding/json"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"github.com/google/uuid"
@@ -54,6 +55,41 @@ func (r *MarketRepository) Save(ctx context.Context, c model.ProviderConfig, lis
return mapError(e)
}
}
if message == "" {
// Quote events are produced only for tracked plans and truly recent vendor observations.
// Re-reading an unchanged close cannot enqueue another price event.
rows, err := tx.Query(ctx, `INSERT INTO veyra_quote_snapshot(id,symbol,exchange,data_mode,price_minor,observed_at,version,source_id,source_version)
SELECT gen_random_uuid()::text,i.symbol,i.exchange,'public',i.price_cents,i.observed_at,1,i.source_id,i.source_version FROM veyra_instrument i
WHERE i.source_id=$1 AND i.source_version=$2 AND i.observed_at>=now()-interval '1 minute' AND i.observed_at<=now()+interval '1 minute'
AND EXISTS(SELECT 1 FROM veyra_trade_plan p JOIN veyra_candidate_revision r ON r.id=p.candidate_revision_id JOIN veyra_candidate c ON c.id=r.candidate_id WHERE `+activePlanPredicate+` AND r.snapshot->>'symbol'=i.symbol AND r.snapshot->>'exchange'=i.exchange)
ON CONFLICT(exchange,symbol,data_mode) DO UPDATE SET price_minor=EXCLUDED.price_minor,observed_at=EXCLUDED.observed_at,version=veyra_quote_snapshot.version+1,source_id=EXCLUDED.source_id,source_version=EXCLUDED.source_version,updated_at=now()
WHERE EXCLUDED.observed_at>veyra_quote_snapshot.observed_at RETURNING `+quoteColumns, c.ID, c.Version)
if err != nil {
return mapError(err)
}
quotes := make([]model.QuoteSnapshot, 0)
for rows.Next() {
q, err := scanQuote(rows)
if err != nil {
rows.Close()
return err
}
quotes = append(quotes, q)
}
rows.Close()
if err := rows.Err(); err != nil {
return mapError(err)
}
for _, q := range quotes {
payload, err := json.Marshal(q)
if err != nil {
return apperror.ErrUnavailable
}
if _, err := tx.Exec(ctx, `INSERT INTO veyra_outbox_event(id,aggregate_type,aggregate_id,aggregate_version,event_type,payload) VALUES($1,'quote_snapshot',$2,$3,'quote.observed',$4)`, uuid.NewString(), q.ID, q.Version, payload); err != nil {
return mapError(err)
}
}
}
_, e = tx.Exec(ctx, `INSERT INTO veyra_market_sync(id,source_id,source_version,instrument_count,status,message,started_at) VALUES($1,$2,$3,$4,$5,$6,$7)`, uuid.NewString(), c.ID, c.Version, len(list), status, message, started)
if e != nil {
return mapError(e)
+23 -6
View File
@@ -60,16 +60,16 @@ func (r *NotificationRepository) Tasks(ctx context.Context) ([]model.Notificatio
return list, mapError(rows.Err())
}
const quoteColumns = `id,symbol,exchange,data_mode,price_minor,observed_at,version,created_at,updated_at`
const quoteColumns = `id,symbol,exchange,data_mode,price_minor,observed_at,version,created_at,updated_at,source_id,source_version`
func scanQuote(row pgx.Row) (model.QuoteSnapshot, error) {
var q model.QuoteSnapshot
err := row.Scan(&q.ID, &q.Symbol, &q.Exchange, &q.DataMode, &q.PriceMinor, &q.ObservedAt, &q.Version, &q.CreatedAt, &q.UpdatedAt)
err := row.Scan(&q.ID, &q.Symbol, &q.Exchange, &q.DataMode, &q.PriceMinor, &q.ObservedAt, &q.Version, &q.CreatedAt, &q.UpdatedAt, &q.SourceID, &q.SourceVersion)
q.Price = model.FormatMoney(q.PriceMinor)
return q, mapError(err)
}
func (r *NotificationRepository) Quotes(ctx context.Context) ([]model.QuoteSnapshot, error) {
rows, err := r.pool.Query(ctx, `SELECT `+quoteColumns+` FROM veyra_quote_snapshot ORDER BY updated_at DESC,id DESC LIMIT 100`)
rows, err := r.pool.Query(ctx, `SELECT `+quoteColumns+` FROM veyra_quote_snapshot WHERE data_mode='simulation' ORDER BY updated_at DESC,id DESC LIMIT 100`)
if err != nil {
return nil, mapError(err)
}
@@ -127,6 +127,19 @@ func processQuote(ctx context.Context, tx pgx.Tx, event model.OutboxEvent) error
if current.Version != event.AggregateVersion || time.Since(current.ObservedAt) > time.Minute || current.ObservedAt.After(time.Now().Add(time.Minute)) {
return nil
}
if current.DataMode == "public" {
if current.SourceID == nil || current.SourceVersion == nil {
return apperror.ErrUnavailable
}
var version int64
var enabled bool
if err := tx.QueryRow(ctx, `SELECT version,enabled FROM veyra_provider_config WHERE id=$1 FOR SHARE`, *current.SourceID).Scan(&version, &enabled); err != nil {
return mapError(err)
}
if !enabled || version != *current.SourceVersion {
return nil
}
}
rows, err := tx.Query(ctx, `SELECT p.id,p.candidate_revision_id,a.user_id FROM veyra_trade_plan p JOIN veyra_candidate_revision r ON r.id=p.candidate_revision_id JOIN veyra_account a ON a.id=p.account_id JOIN veyra_user u ON u.id=a.user_id WHERE p.status IN ('ACTIVE','PART_FILLED') AND u.status='ACTIVE' AND r.snapshot->>'symbol'=$1 AND r.snapshot->>'exchange'=$2 ORDER BY r.candidate_id,p.id`, current.Symbol, current.Exchange)
if err != nil {
return mapError(err)
@@ -176,7 +189,11 @@ func processQuote(ctx context.Context, tx pgx.Tx, event model.OutboxEvent) error
if tag.RowsAffected() == 0 {
continue
}
body := fmt.Sprintf("模拟价格 ¥%s 进入 %s 的参考区间。观察时间 %s。请打开计划核对当前状态;此提醒不代表成交。", current.Price, publication.Snapshot.Name, current.ObservedAt.Format(time.RFC3339))
label := "模拟价格"
if current.DataMode == "public" {
label = "公开行情价格"
}
body := fmt.Sprintf("%s ¥%s 进入 %s 的参考区间。观察时间 %s。请打开计划核对当前状态;此提醒不代表成交。", label, current.Price, publication.Snapshot.Name, current.ObservedAt.Format(time.RFC3339))
if err := insertNotification(ctx, tx, target.user, event.ID, &target.plan, "模拟计划条件已满足", body); err != nil {
return err
}
@@ -228,7 +245,7 @@ func (r *NotificationRepository) ProcessNext(ctx context.Context) (bool, error)
var event model.OutboxEvent
err = tx.QueryRow(ctx, `SELECT id,aggregate_type,aggregate_id,aggregate_version,event_type,payload,created_at,processed_at FROM veyra_outbox_event WHERE processed_at IS NULL ORDER BY created_at,id LIMIT 1 FOR UPDATE SKIP LOCKED`).Scan(&event.ID, &event.AggregateType, &event.AggregateID, &event.AggregateVersion, &event.EventType, &event.Payload, &event.CreatedAt, &event.ProcessedAt)
if err == pgx.ErrNoRows {
rows, err := tx.Query(ctx, `SELECT DISTINCT a.user_id FROM veyra_trade_plan p JOIN veyra_account a ON a.id=p.account_id JOIN veyra_candidate_revision r ON r.id=p.candidate_revision_id JOIN veyra_candidate c ON c.id=r.candidate_id WHERE p.status IN ('ACTIVE','PART_FILLED') AND (p.valid_until<=now() OR r.valid_until<=now() OR r.status<>'PUBLISHED' OR c.published_revision_id IS DISTINCT FROM r.id) LIMIT 100`)
rows, err := tx.Query(ctx, `SELECT DISTINCT a.user_id FROM veyra_trade_plan p JOIN veyra_account a ON a.id=p.account_id JOIN veyra_candidate_revision r ON r.id=p.candidate_revision_id JOIN veyra_candidate c ON c.id=r.candidate_id WHERE p.status IN ('ACTIVE','PART_FILLED') AND (p.valid_until<=now() OR r.valid_until<=now() OR r.status<>'PUBLISHED' OR c.published_revision_id IS DISTINCT FROM r.id OR NOT (`+screeningEligibility+`)) LIMIT 100`)
if err != nil {
return false, mapError(err)
}
@@ -265,7 +282,7 @@ func (r *NotificationRepository) ProcessNext(ctx context.Context) (bool, error)
var plan string
err = tx.QueryRow(ctx, `SELECT a.user_id,p.id FROM veyra_trade_plan p JOIN veyra_account a ON a.id=p.account_id WHERE p.id=$1`, event.AggregateID).Scan(&user, &plan)
if err == nil {
err = insertNotification(ctx, tx, user, event.ID, &plan, "计划已失效", "对应候选已失效或计划已过期,保留预算已释放。已记录成交与持仓保留。")
err = insertNotification(ctx, tx, user, event.ID, &plan, "计划已失效", "对应候选、策略版本已失效或计划已过期,保留预算已释放。已记录成交与持仓保留。")
}
case "candidate.published", "position.changed":
default:
+128
View File
@@ -0,0 +1,128 @@
package repository
import (
"context"
"encoding/json"
"time"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type ScreeningRepository struct{ pool *pgxpool.Pool }
func NewScreeningRepository(pool *pgxpool.Pool) *ScreeningRepository {
return &ScreeningRepository{pool}
}
func (r *ScreeningRepository) Targets(ctx context.Context, c model.ProviderConfig) ([]model.ScreeningTarget, error) {
rows, err := r.pool.Query(ctx, `SELECT s.id,s.name,v.id,v.strategy_id,v.revision,v.algorithm,v.parameters,v.description,v.author_id,v.created_at,i.id,i.symbol,i.exchange,i.name,i.price_cents,i.observed_at,i.received_at,i.source_id,i.source_version
FROM veyra_strategy s JOIN veyra_strategy_version v ON v.id=s.active_version_id
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(v.parameters->'screening'->'securities','[]'::jsonb)) security
JOIN veyra_instrument i ON i.symbol=security->>'symbol' AND i.exchange=security->>'exchange'
JOIN veyra_user u ON u.id=v.author_id AND u.role='ADMIN' AND u.status='ACTIVE'
WHERE s.status='ACTIVE' AND i.source_id=$1 AND i.source_version=$2 AND i.observed_at>=now()-interval '24 hours' AND i.observed_at<=now()+interval '1 minute' AND i.received_at>=now()-interval '10 minutes'
AND ((i.observed_at AT TIME ZONE 'Asia/Shanghai')::date < (now() AT TIME ZONE 'Asia/Shanghai')::date OR EXTRACT(hour FROM now() AT TIME ZONE 'Asia/Shanghai')>=16)
AND NOT EXISTS(SELECT 1 FROM veyra_strategy_screening x WHERE x.strategy_version_id=v.id AND x.symbol=i.symbol AND x.exchange=i.exchange AND x.bar_date=(i.observed_at AT TIME ZONE 'Asia/Shanghai')::date AND x.source_id=$1 AND x.source_version=$2)
ORDER BY s.updated_at,s.id,i.symbol LIMIT 2000`, c.ID, c.Version)
if err != nil {
return nil, mapError(err)
}
defer rows.Close()
targets := make([]model.ScreeningTarget, 0)
for rows.Next() {
var t model.ScreeningTarget
var raw []byte
v := &t.Version
i := &t.Instrument
if err := rows.Scan(&t.StrategyID, &t.StrategyName, &v.ID, &v.StrategyID, &v.Revision, &v.Algorithm, &raw, &v.Description, &v.AuthorID, &v.CreatedAt, &i.ID, &i.Symbol, &i.Exchange, &i.Name, &i.PriceCents, &i.ObservedAt, &i.ReceivedAt, &i.SourceID, &i.SourceVersion); err != nil {
return nil, mapError(err)
}
if json.Unmarshal(raw, &v.Parameters) != nil {
return nil, apperror.ErrUnavailable
}
targets = append(targets, t)
}
return targets, mapError(rows.Err())
}
func (r *ScreeningRepository) Record(ctx context.Context, t model.ScreeningTarget, run model.StrategyScreening, draft *model.CandidateDraftInput) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return mapError(err)
}
defer tx.Rollback(context.Background())
// Lock deployment before creating a candidate; pause/version changes cannot race publication.
var status string
var active *string
if err := tx.QueryRow(ctx, `SELECT status,active_version_id FROM veyra_strategy WHERE id=$1 FOR SHARE`, t.StrategyID).Scan(&status, &active); err != nil {
return mapError(err)
}
if status != "ACTIVE" || active == nil || *active != t.Version.ID {
return apperror.ErrConflict
}
var sourceVersion int64
var enabled bool
if err := tx.QueryRow(ctx, `SELECT version,enabled FROM veyra_provider_config WHERE id=$1 FOR SHARE`, run.SourceID).Scan(&sourceVersion, &enabled); err != nil {
return mapError(err)
}
if !enabled || sourceVersion != run.SourceVersion {
return apperror.ErrConflict
}
var actorValid bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM veyra_user WHERE id=$1 AND status='ACTIVE' AND role='ADMIN')`, t.Version.AuthorID).Scan(&actorValid); err != nil {
return mapError(err)
}
if !actorValid {
return apperror.ErrConflict
}
requestID := uuid.NewString()
var revisionID *string
if draft != nil {
if err := model.ValidateDraft(*draft, time.Now().UTC()); err != nil {
return err
}
candidate, err := createCandidate(ctx, tx, *draft, t.Version.AuthorID, requestID, "", 0)
if err != nil {
return err
}
// Machine submission leaves the approval decision to an administrator.
if _, err := tx.Exec(ctx, `UPDATE veyra_candidate_revision SET status='PENDING_REVIEW',version=version+1 WHERE id=$1`, candidate.ID); err != nil {
return mapError(err)
}
if _, err := tx.Exec(ctx, `INSERT INTO veyra_candidate_review(id,revision_id,reviewer_id,decision,input_version,reason) VALUES($1,$2,$3,'SUBMIT',1,'自动日线筛选提交,等待人工审核')`, uuid.NewString(), candidate.ID, t.Version.AuthorID); err != nil {
return mapError(err)
}
revisionID = &candidate.ID
}
_, err = tx.Exec(ctx, `INSERT INTO veyra_strategy_screening(id,strategy_version_id,symbol,exchange,bar_date,source_id,source_version,source_adapter,observed_at,received_at,input_hash,input,status,candidate_revision_id) VALUES($1,$2,$3,$4,$5::date,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, uuid.NewString(), t.Version.ID, run.Symbol, run.Exchange, run.BarDate, run.SourceID, run.SourceVersion, run.SourceAdapter, run.ObservedAt, run.ReceivedAt, run.InputHash, run.Input, run.Status, revisionID)
if err != nil {
return mapError(err)
} // Unique key rolls back candidate/audit too, including concurrent workers.
if err := appendAudit(ctx, tx, t.Version.AuthorID, "STRATEGY_SCREENING_COMPLETED", "strategy_version", t.Version.ID, requestID, map[string]any{"symbol": run.Symbol, "exchange": run.Exchange, "bar_date": run.BarDate, "status": run.Status, "input_hash": run.InputHash, "candidate_revision_id": revisionID}); err != nil {
return err
}
return mapError(tx.Commit(ctx))
}
func (r *ScreeningRepository) List(ctx context.Context) ([]model.StrategyScreening, error) {
rows, err := r.pool.Query(ctx, `SELECT x.id,x.strategy_version_id,s.name,v.revision,x.symbol,x.exchange,x.bar_date::text,x.source_id,x.source_version,x.source_adapter,x.observed_at,x.received_at,x.input_hash,x.status,x.candidate_revision_id,x.created_at FROM veyra_strategy_screening x JOIN veyra_strategy_version v ON v.id=x.strategy_version_id JOIN veyra_strategy s ON s.id=v.strategy_id ORDER BY x.created_at DESC,x.id DESC LIMIT 100`)
if err != nil {
return nil, mapError(err)
}
defer rows.Close()
list := make([]model.StrategyScreening, 0)
for rows.Next() {
var x model.StrategyScreening
if err := rows.Scan(&x.ID, &x.StrategyVersionID, &x.StrategyName, &x.StrategyRevision, &x.Symbol, &x.Exchange, &x.BarDate, &x.SourceID, &x.SourceVersion, &x.SourceAdapter, &x.ObservedAt, &x.ReceivedAt, &x.InputHash, &x.Status, &x.CandidateRevisionID, &x.CreatedAt); err != nil {
return nil, mapError(err)
}
list = append(list, x)
}
return list, mapError(rows.Err())
}
func (r *ScreeningRepository) Detail(ctx context.Context, id string) (model.StrategyScreening, error) {
var x model.StrategyScreening
err := r.pool.QueryRow(ctx, `SELECT x.id,x.strategy_version_id,s.name,v.revision,x.symbol,x.exchange,x.bar_date::text,x.source_id,x.source_version,x.source_adapter,x.observed_at,x.received_at,x.input_hash,x.status,x.candidate_revision_id,x.created_at,x.input FROM veyra_strategy_screening x JOIN veyra_strategy_version v ON v.id=x.strategy_version_id JOIN veyra_strategy s ON s.id=v.strategy_id WHERE x.id=$1`, id).Scan(&x.ID, &x.StrategyVersionID, &x.StrategyName, &x.StrategyRevision, &x.Symbol, &x.Exchange, &x.BarDate, &x.SourceID, &x.SourceVersion, &x.SourceAdapter, &x.ObservedAt, &x.ReceivedAt, &x.InputHash, &x.Status, &x.CandidateRevisionID, &x.CreatedAt, &x.Input)
return x, mapError(err)
}
+2 -2
View File
@@ -27,7 +27,7 @@ func lockSimulationAccount(ctx context.Context, tx pgx.Tx, user, id string) erro
return nil
}
const activePlanPredicate = `p.status IN ('ACTIVE','PART_FILLED') AND p.valid_until>now() AND r.status='PUBLISHED' AND c.published_revision_id=r.id AND r.valid_until>now()`
const activePlanPredicate = `p.status IN ('ACTIVE','PART_FILLED') AND p.valid_until>now() AND r.status='PUBLISHED' AND c.published_revision_id=r.id AND r.valid_until>now() AND ` + screeningEligibility
func budgetMinor(ctx context.Context, tx pgx.Tx, account, exclude string) (int64, int64, error) {
var cash, reserved int64
@@ -151,7 +151,7 @@ func (r *TradePlanRepository) Confirm(ctx context.Context, user, id, requestID s
return plan, mapError(tx.Commit(ctx))
}
func invalidatePlans(ctx context.Context, tx pgx.Tx, user string) error {
rows, err := tx.Query(ctx, `UPDATE veyra_trade_plan p SET status='INVALIDATED',reserved_minor=0,version=p.version+1,updated_at=now() FROM veyra_candidate_revision r,veyra_candidate c,veyra_account a WHERE p.candidate_revision_id=r.id AND c.id=r.candidate_id AND a.id=p.account_id AND a.user_id=$1 AND p.status IN ('ACTIVE','PART_FILLED') AND (p.valid_until<=now() OR r.valid_until<=now() OR r.status<>'PUBLISHED' OR c.published_revision_id IS DISTINCT FROM r.id) RETURNING p.id,p.version`, user)
rows, err := tx.Query(ctx, `UPDATE veyra_trade_plan p SET status='INVALIDATED',reserved_minor=0,version=p.version+1,updated_at=now() FROM veyra_candidate_revision r,veyra_candidate c,veyra_account a WHERE p.candidate_revision_id=r.id AND c.id=r.candidate_id AND a.id=p.account_id AND a.user_id=$1 AND p.status IN ('ACTIVE','PART_FILLED') AND (p.valid_until<=now() OR r.valid_until<=now() OR r.status<>'PUBLISHED' OR c.published_revision_id IS DISTINCT FROM r.id OR NOT (`+screeningEligibility+`)) RETURNING p.id,p.version`, user)
if err != nil {
return mapError(err)
}
+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) {