Checks / verify (push) Failing after 9m52s
统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
184 lines
6.7 KiB
Go
184 lines
6.7 KiB
Go
package model
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
|
)
|
|
|
|
type Candidate struct {
|
|
ID string `db:"id" json:"id"`
|
|
LatestRevision int `db:"latest_revision" json:"latest_revision"`
|
|
PublishedRevisionID *string `db:"published_revision_id" json:"published_revision_id"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
}
|
|
|
|
func (Candidate) TableName() string { return "veyra_candidate" }
|
|
|
|
type CandidateEvidence struct {
|
|
SourceURL string `json:"source_url"`
|
|
Locator string `json:"locator"`
|
|
Summary string `json:"summary"`
|
|
}
|
|
type CandidateReferencePlan struct {
|
|
EntryLow string `json:"entry_low"`
|
|
EntryHigh string `json:"entry_high"`
|
|
StopLoss string `json:"stop_loss"`
|
|
TargetPrice string `json:"target_price"`
|
|
}
|
|
type CandidateContent struct {
|
|
Symbol string `json:"symbol"`
|
|
Exchange string `json:"exchange"`
|
|
Name string `json:"name"`
|
|
StrategyCode string `json:"strategy_code"`
|
|
Title string `json:"title"`
|
|
Thesis string `json:"thesis"`
|
|
Evidence []CandidateEvidence `json:"evidence"`
|
|
CounterEvidence string `json:"counter_evidence"`
|
|
Invalidation string `json:"invalidation"`
|
|
ReferencePlan CandidateReferencePlan `json:"reference_plan"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
func (CandidateRevision) TableName() string { return "veyra_candidate_revision" }
|
|
|
|
type CandidateReview struct {
|
|
ID string `db:"id" json:"id"`
|
|
RevisionID string `db:"revision_id" json:"revision_id"`
|
|
ReviewerID string `db:"reviewer_id" json:"reviewer_id"`
|
|
Decision string `db:"decision" json:"decision"`
|
|
InputVersion int64 `db:"input_version" json:"input_version"`
|
|
Reason string `db:"reason" json:"reason"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
}
|
|
|
|
func (CandidateReview) TableName() string { return "veyra_candidate_review" }
|
|
|
|
type CandidateDraftInput struct {
|
|
Content CandidateContent `json:"content"`
|
|
ObservedAt time.Time `json:"observed_at"`
|
|
ValidUntil time.Time `json:"valid_until"`
|
|
}
|
|
type CandidateRevisionInput struct {
|
|
CandidateDraftInput
|
|
ExpectedVersion int64 `json:"expected_version"`
|
|
}
|
|
type CandidateActionInput struct {
|
|
ExpectedVersion int64 `json:"expected_version"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
type CandidateDetail struct {
|
|
CandidateRevision
|
|
Reviews []CandidateReview `json:"reviews"`
|
|
}
|
|
|
|
var symbolPattern = regexp.MustCompile(`^[0-9]{6}$`)
|
|
|
|
func textValid(s string, max int) bool {
|
|
return strings.TrimSpace(s) != "" && utf8.RuneCountInString(s) <= max
|
|
}
|
|
|
|
func ValidateDraft(input CandidateDraftInput, now time.Time) error {
|
|
c := input.Content
|
|
if !symbolPattern.MatchString(c.Symbol) || c.Exchange != "SSE" && c.Exchange != "SZSE" || !textValid(c.Name, 80) || !textValid(c.Title, 120) || !textValid(c.Thesis, 2000) || !textValid(c.CounterEvidence, 1000) || !textValid(c.Invalidation, 1000) {
|
|
return apperror.ErrInvalidInput
|
|
}
|
|
switch c.StrategyCode {
|
|
case "trend_following", "pullback", "event_driven":
|
|
default:
|
|
return apperror.ErrInvalidInput
|
|
}
|
|
if len(c.Evidence) < 1 || len(c.Evidence) > 8 {
|
|
return apperror.ErrInvalidInput
|
|
}
|
|
for _, e := range c.Evidence {
|
|
u, err := url.Parse(e.SourceURL)
|
|
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || len(e.SourceURL) > 2048 || !textValid(e.Locator, 200) || !textValid(e.Summary, 1000) {
|
|
return apperror.ErrInvalidInput
|
|
}
|
|
}
|
|
low, e1 := ParseMoney(c.ReferencePlan.EntryLow)
|
|
high, e2 := ParseMoney(c.ReferencePlan.EntryHigh)
|
|
stop, e3 := ParseMoney(c.ReferencePlan.StopLoss)
|
|
target, e4 := ParseMoney(c.ReferencePlan.TargetPrice)
|
|
if e1 != nil || e2 != nil || e3 != nil || e4 != nil || stop <= 0 || stop >= low || low > high || target <= high {
|
|
return apperror.ErrInvalidInput
|
|
}
|
|
if input.ObservedAt.IsZero() || input.ObservedAt.After(now.Add(time.Minute)) || input.ObservedAt.Before(now.Add(-24*time.Hour)) || !input.ValidUntil.After(now) || input.ValidUntil.After(now.Add(7*24*time.Hour)) || !input.ValidUntil.After(input.ObservedAt) {
|
|
return apperror.ErrInvalidInput
|
|
}
|
|
encoded, err := json.Marshal(c)
|
|
if err != nil || len(encoded) > 12<<10 {
|
|
return apperror.ErrInvalidInput
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NextStatus is the shared state rule; repositories recheck it under the aggregate lock.
|
|
func NextStatus(r CandidateRevision, action string, now time.Time) (string, error) {
|
|
if action == "WITHDRAW" {
|
|
if r.Status != "PUBLISHED" || !r.IsActivePublication {
|
|
return "", apperror.ErrConflict
|
|
}
|
|
return "WITHDRAWN", nil
|
|
}
|
|
if !r.IsLatest {
|
|
return "", apperror.ErrConflict
|
|
}
|
|
switch action {
|
|
case "SUBMIT":
|
|
if r.Status != "DRAFT" {
|
|
return "", apperror.ErrConflict
|
|
}
|
|
if err := ValidateDraft(CandidateDraftInput{Content: r.Snapshot, ObservedAt: r.ObservedAt, ValidUntil: r.ValidUntil}, now); err != nil {
|
|
return "", apperror.ErrInvalidInput
|
|
}
|
|
return "PENDING_REVIEW", nil
|
|
case "PUBLISH":
|
|
if r.Status != "PENDING_REVIEW" {
|
|
return "", apperror.ErrConflict
|
|
}
|
|
if err := ValidateDraft(CandidateDraftInput{Content: r.Snapshot, ObservedAt: r.ObservedAt, ValidUntil: r.ValidUntil}, now); err != nil {
|
|
return "", apperror.ErrInvalidInput
|
|
}
|
|
return "PUBLISHED", nil
|
|
case "RETURN":
|
|
if r.Status != "PENDING_REVIEW" {
|
|
return "", apperror.ErrConflict
|
|
}
|
|
return "RETURNED", nil
|
|
default:
|
|
return "", apperror.ErrInvalidInput
|
|
}
|
|
}
|
|
|
|
func ValidateReason(reason string) error {
|
|
if !textValid(reason, 1000) {
|
|
return apperror.ErrInvalidInput
|
|
}
|
|
return nil
|
|
}
|