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
+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 {