feat: 自动候选筛选与股票研究工作台
This commit is contained in:
@@ -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));
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user