Files
Veyra/internal/model/candidate.go
T

186 lines
6.9 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 {
AIAnalysis *CandidateAnalysis `json:"ai_analysis,omitempty"`
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" }
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
}