mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
2d272bb7b8
- Migrate all store packages from raw database/sql to GORM ORM - Add PostgreSQL support alongside SQLite - Move EncryptedString type to crypto package for cleaner architecture - Add automatic encryption/decryption for sensitive fields (API keys, secrets) - Fix PostgreSQL AutoMigrate conflicts by skipping existing tables - Fix duplicate /klines route registration - Update tests to use GORM database connections - Add database configuration support in config package
497 lines
18 KiB
Go
497 lines
18 KiB
Go
package store
|
|
|
|
import (
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// DebateStatus represents the status of a debate session
|
|
type DebateStatus string
|
|
|
|
const (
|
|
DebateStatusPending DebateStatus = "pending"
|
|
DebateStatusRunning DebateStatus = "running"
|
|
DebateStatusVoting DebateStatus = "voting"
|
|
DebateStatusCompleted DebateStatus = "completed"
|
|
DebateStatusCancelled DebateStatus = "cancelled"
|
|
)
|
|
|
|
// DebatePersonality represents AI personality types
|
|
type DebatePersonality string
|
|
|
|
const (
|
|
PersonalityBull DebatePersonality = "bull" // Aggressive Bull - looks for long opportunities
|
|
PersonalityBear DebatePersonality = "bear" // Cautious Bear - skeptical, focuses on risks
|
|
PersonalityAnalyst DebatePersonality = "analyst" // Data Analyst - pure technical analysis
|
|
PersonalityContrarian DebatePersonality = "contrarian" // Contrarian - challenges majority opinion
|
|
PersonalityRiskManager DebatePersonality = "risk_manager" // Risk Manager - focuses on position sizing
|
|
)
|
|
|
|
// PersonalityColors maps personalities to colors for UI
|
|
var PersonalityColors = map[DebatePersonality]string{
|
|
PersonalityBull: "#22C55E", // Green
|
|
PersonalityBear: "#EF4444", // Red
|
|
PersonalityAnalyst: "#3B82F6", // Blue
|
|
PersonalityContrarian: "#F59E0B", // Amber
|
|
PersonalityRiskManager: "#8B5CF6", // Purple
|
|
}
|
|
|
|
// PersonalityEmojis maps personalities to emojis
|
|
var PersonalityEmojis = map[DebatePersonality]string{
|
|
PersonalityBull: "🐂",
|
|
PersonalityBear: "🐻",
|
|
PersonalityAnalyst: "📊",
|
|
PersonalityContrarian: "🔄",
|
|
PersonalityRiskManager: "🛡️",
|
|
}
|
|
|
|
// DebateDecision represents a trading decision from the debate
|
|
type DebateDecision struct {
|
|
Action string `json:"action"` // open_long/open_short/close_long/close_short/hold/wait
|
|
Symbol string `json:"symbol"` // Trading pair
|
|
Confidence int `json:"confidence"` // 0-100
|
|
Leverage int `json:"leverage"` // Recommended leverage
|
|
PositionPct float64 `json:"position_pct"` // Position size as percentage of equity (0.0-1.0)
|
|
PositionSizeUSD float64 `json:"position_size_usd"` // Position size in USD (calculated from pct)
|
|
StopLoss float64 `json:"stop_loss"` // Stop loss price
|
|
TakeProfit float64 `json:"take_profit"` // Take profit price
|
|
Reasoning string `json:"reasoning"` // Brief reasoning
|
|
|
|
// Execution tracking
|
|
Executed bool `json:"executed"` // Whether this decision was executed
|
|
ExecutedAt time.Time `json:"executed_at,omitempty"` // When it was executed
|
|
OrderID string `json:"order_id,omitempty"` // Exchange order ID
|
|
Error string `json:"error,omitempty"` // Execution error if any
|
|
}
|
|
|
|
// DebateSession represents a debate session (API struct)
|
|
type DebateSession struct {
|
|
ID string `json:"id"`
|
|
UserID string `json:"user_id"`
|
|
Name string `json:"name"`
|
|
StrategyID string `json:"strategy_id"`
|
|
Status DebateStatus `json:"status"`
|
|
Symbol string `json:"symbol"` // Primary symbol (for backward compat, may be empty for multi-coin)
|
|
MaxRounds int `json:"max_rounds"`
|
|
CurrentRound int `json:"current_round"`
|
|
IntervalMinutes int `json:"interval_minutes"` // Debate interval (5, 15, 30, 60 minutes)
|
|
PromptVariant string `json:"prompt_variant"` // balanced/aggressive/conservative/scalping
|
|
FinalDecision *DebateDecision `json:"final_decision,omitempty"` // Single decision (backward compat)
|
|
FinalDecisions []*DebateDecision `json:"final_decisions,omitempty"` // Multi-coin decisions
|
|
AutoExecute bool `json:"auto_execute"`
|
|
TraderID string `json:"trader_id,omitempty"` // Trader to use for auto-execute
|
|
// OI Ranking data options
|
|
EnableOIRanking bool `json:"enable_oi_ranking"` // Whether to include OI ranking data
|
|
OIRankingLimit int `json:"oi_ranking_limit"` // Number of OI ranking entries (default 10)
|
|
OIDuration string `json:"oi_duration"` // Duration for OI data (1h, 4h, 24h, etc.)
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// DebateSessionDB is the GORM model for debate_sessions
|
|
type DebateSessionDB struct {
|
|
ID string `gorm:"column:id;primaryKey"`
|
|
UserID string `gorm:"column:user_id;not null;index"`
|
|
Name string `gorm:"column:name;not null"`
|
|
StrategyID string `gorm:"column:strategy_id;not null"`
|
|
Status DebateStatus `gorm:"column:status;not null;default:pending;index"`
|
|
Symbol string `gorm:"column:symbol;not null"`
|
|
MaxRounds int `gorm:"column:max_rounds;default:3"`
|
|
CurrentRound int `gorm:"column:current_round;default:0"`
|
|
IntervalMinutes int `gorm:"column:interval_minutes;default:5"`
|
|
PromptVariant string `gorm:"column:prompt_variant;default:balanced"`
|
|
FinalDecision string `gorm:"column:final_decision"` // JSON string
|
|
AutoExecute bool `gorm:"column:auto_execute;default:false"`
|
|
TraderID string `gorm:"column:trader_id"`
|
|
EnableOIRanking bool `gorm:"column:enable_oi_ranking;default:false"`
|
|
OIRankingLimit int `gorm:"column:oi_ranking_limit;default:10"`
|
|
OIDuration string `gorm:"column:oi_duration;default:1h"`
|
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
|
|
}
|
|
|
|
func (DebateSessionDB) TableName() string {
|
|
return "debate_sessions"
|
|
}
|
|
|
|
func (db *DebateSessionDB) toSession() *DebateSession {
|
|
s := &DebateSession{
|
|
ID: db.ID,
|
|
UserID: db.UserID,
|
|
Name: db.Name,
|
|
StrategyID: db.StrategyID,
|
|
Status: db.Status,
|
|
Symbol: db.Symbol,
|
|
MaxRounds: db.MaxRounds,
|
|
CurrentRound: db.CurrentRound,
|
|
IntervalMinutes: db.IntervalMinutes,
|
|
PromptVariant: db.PromptVariant,
|
|
AutoExecute: db.AutoExecute,
|
|
TraderID: db.TraderID,
|
|
EnableOIRanking: db.EnableOIRanking,
|
|
OIRankingLimit: db.OIRankingLimit,
|
|
OIDuration: db.OIDuration,
|
|
CreatedAt: db.CreatedAt,
|
|
UpdatedAt: db.UpdatedAt,
|
|
}
|
|
|
|
// Set defaults
|
|
if s.IntervalMinutes == 0 {
|
|
s.IntervalMinutes = 5
|
|
}
|
|
if s.PromptVariant == "" {
|
|
s.PromptVariant = "balanced"
|
|
}
|
|
if s.OIRankingLimit == 0 {
|
|
s.OIRankingLimit = 10
|
|
}
|
|
if s.OIDuration == "" {
|
|
s.OIDuration = "1h"
|
|
}
|
|
|
|
// Parse final decision
|
|
if db.FinalDecision != "" {
|
|
var decision DebateDecision
|
|
if json.Unmarshal([]byte(db.FinalDecision), &decision) == nil {
|
|
s.FinalDecision = &decision
|
|
}
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// DebateParticipant represents an AI participant in a debate
|
|
type DebateParticipant struct {
|
|
ID string `gorm:"column:id;primaryKey" json:"id"`
|
|
SessionID string `gorm:"column:session_id;not null;index" json:"session_id"`
|
|
AIModelID string `gorm:"column:ai_model_id;not null" json:"ai_model_id"`
|
|
AIModelName string `gorm:"column:ai_model_name;not null" json:"ai_model_name"`
|
|
Provider string `gorm:"column:provider;not null" json:"provider"`
|
|
Personality DebatePersonality `gorm:"column:personality;not null" json:"personality"`
|
|
Color string `gorm:"column:color;not null" json:"color"`
|
|
SpeakOrder int `gorm:"column:speak_order;default:0" json:"speak_order"`
|
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
|
}
|
|
|
|
func (DebateParticipant) TableName() string {
|
|
return "debate_participants"
|
|
}
|
|
|
|
// DebateMessage represents a message in the debate
|
|
type DebateMessage struct {
|
|
ID string `gorm:"column:id;primaryKey" json:"id"`
|
|
SessionID string `gorm:"column:session_id;not null;index" json:"session_id"`
|
|
Round int `gorm:"column:round;not null" json:"round"`
|
|
AIModelID string `gorm:"column:ai_model_id;not null" json:"ai_model_id"`
|
|
AIModelName string `gorm:"column:ai_model_name;not null" json:"ai_model_name"`
|
|
Provider string `gorm:"column:provider;not null" json:"provider"`
|
|
Personality DebatePersonality `gorm:"column:personality;not null" json:"personality"`
|
|
MessageType string `gorm:"column:message_type;not null" json:"message_type"` // analysis/rebuttal/final/vote
|
|
Content string `gorm:"column:content;not null" json:"content"`
|
|
DecisionRaw string `gorm:"column:decision" json:"-"` // JSON string in DB
|
|
Decision *DebateDecision `gorm:"-" json:"decision,omitempty"` // Parsed for API
|
|
Decisions []*DebateDecision `gorm:"-" json:"decisions,omitempty"` // Multi-coin decisions
|
|
Confidence int `gorm:"column:confidence;default:0" json:"confidence"`
|
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
|
}
|
|
|
|
func (DebateMessage) TableName() string {
|
|
return "debate_messages"
|
|
}
|
|
|
|
// DebateVote represents a final vote from an AI (can contain multiple coin decisions)
|
|
type DebateVote struct {
|
|
ID string `gorm:"column:id;primaryKey" json:"id"`
|
|
SessionID string `gorm:"column:session_id;not null;index" json:"session_id"`
|
|
AIModelID string `gorm:"column:ai_model_id;not null" json:"ai_model_id"`
|
|
AIModelName string `gorm:"column:ai_model_name;not null" json:"ai_model_name"`
|
|
Action string `gorm:"column:action;not null" json:"action"` // Primary action (backward compat)
|
|
Symbol string `gorm:"column:symbol;not null" json:"symbol"` // Primary symbol (backward compat)
|
|
Confidence int `gorm:"column:confidence;default:0" json:"confidence"`
|
|
Leverage int `gorm:"column:leverage;default:5" json:"leverage"`
|
|
PositionPct float64 `gorm:"column:position_pct;default:0.2" json:"position_pct"`
|
|
StopLossPct float64 `gorm:"column:stop_loss_pct;default:0.03" json:"stop_loss_pct"`
|
|
TakeProfitPct float64 `gorm:"column:take_profit_pct;default:0.06" json:"take_profit_pct"`
|
|
Reasoning string `gorm:"column:reasoning" json:"reasoning"`
|
|
Decisions []*DebateDecision `gorm:"-" json:"decisions,omitempty"` // Multi-coin decisions
|
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
|
}
|
|
|
|
func (DebateVote) TableName() string {
|
|
return "debate_votes"
|
|
}
|
|
|
|
// DebateStore handles database operations for debates
|
|
type DebateStore struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// NewDebateStore creates a new DebateStore
|
|
func NewDebateStore(db *gorm.DB) *DebateStore {
|
|
return &DebateStore{db: db}
|
|
}
|
|
|
|
// InitSchema creates the debate tables using GORM AutoMigrate
|
|
func (s *DebateStore) InitSchema() error {
|
|
return s.db.AutoMigrate(
|
|
&DebateSessionDB{},
|
|
&DebateParticipant{},
|
|
&DebateMessage{},
|
|
&DebateVote{},
|
|
)
|
|
}
|
|
|
|
// CreateSession creates a new debate session
|
|
func (s *DebateStore) CreateSession(session *DebateSession) error {
|
|
if session.ID == "" {
|
|
session.ID = uuid.New().String()
|
|
}
|
|
session.Status = DebateStatusPending
|
|
session.CurrentRound = 0
|
|
if session.IntervalMinutes == 0 {
|
|
session.IntervalMinutes = 5
|
|
}
|
|
if session.PromptVariant == "" {
|
|
session.PromptVariant = "balanced"
|
|
}
|
|
if session.OIRankingLimit == 0 {
|
|
session.OIRankingLimit = 10
|
|
}
|
|
if session.OIDuration == "" {
|
|
session.OIDuration = "1h"
|
|
}
|
|
|
|
db := &DebateSessionDB{
|
|
ID: session.ID,
|
|
UserID: session.UserID,
|
|
Name: session.Name,
|
|
StrategyID: session.StrategyID,
|
|
Status: session.Status,
|
|
Symbol: session.Symbol,
|
|
MaxRounds: session.MaxRounds,
|
|
CurrentRound: session.CurrentRound,
|
|
IntervalMinutes: session.IntervalMinutes,
|
|
PromptVariant: session.PromptVariant,
|
|
AutoExecute: session.AutoExecute,
|
|
TraderID: session.TraderID,
|
|
EnableOIRanking: session.EnableOIRanking,
|
|
OIRankingLimit: session.OIRankingLimit,
|
|
OIDuration: session.OIDuration,
|
|
}
|
|
|
|
return s.db.Create(db).Error
|
|
}
|
|
|
|
// GetSession gets a debate session by ID
|
|
func (s *DebateStore) GetSession(id string) (*DebateSession, error) {
|
|
var db DebateSessionDB
|
|
if err := s.db.Where("id = ?", id).First(&db).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return db.toSession(), nil
|
|
}
|
|
|
|
// GetSessionsByUser gets all debate sessions for a user
|
|
func (s *DebateStore) GetSessionsByUser(userID string) ([]*DebateSession, error) {
|
|
var dbs []DebateSessionDB
|
|
if err := s.db.Where("user_id = ?", userID).Order("created_at DESC").Find(&dbs).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sessions := make([]*DebateSession, len(dbs))
|
|
for i, db := range dbs {
|
|
sessions[i] = db.toSession()
|
|
}
|
|
return sessions, nil
|
|
}
|
|
|
|
// ListAllSessions returns all debate sessions (for cleanup on startup)
|
|
func (s *DebateStore) ListAllSessions() ([]*DebateSession, error) {
|
|
var dbs []DebateSessionDB
|
|
if err := s.db.Select("id, status").Find(&dbs).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sessions := make([]*DebateSession, len(dbs))
|
|
for i, db := range dbs {
|
|
sessions[i] = &DebateSession{ID: db.ID, Status: db.Status}
|
|
}
|
|
return sessions, nil
|
|
}
|
|
|
|
// UpdateSessionStatus updates the status of a debate session
|
|
func (s *DebateStore) UpdateSessionStatus(id string, status DebateStatus) error {
|
|
return s.db.Model(&DebateSessionDB{}).Where("id = ?", id).Update("status", status).Error
|
|
}
|
|
|
|
// UpdateSessionRound updates the current round of a debate session
|
|
func (s *DebateStore) UpdateSessionRound(id string, round int) error {
|
|
return s.db.Model(&DebateSessionDB{}).Where("id = ?", id).Update("current_round", round).Error
|
|
}
|
|
|
|
// UpdateSessionFinalDecision updates the final decision of a debate session (single decision)
|
|
func (s *DebateStore) UpdateSessionFinalDecision(id string, decision *DebateDecision) error {
|
|
decisionJSON, err := json.Marshal(decision)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.db.Model(&DebateSessionDB{}).Where("id = ?", id).Updates(map[string]interface{}{
|
|
"final_decision": string(decisionJSON),
|
|
"status": DebateStatusCompleted,
|
|
}).Error
|
|
}
|
|
|
|
// UpdateSessionFinalDecisions updates both single and multi-coin final decisions
|
|
func (s *DebateStore) UpdateSessionFinalDecisions(id string, primaryDecision *DebateDecision, allDecisions []*DebateDecision) error {
|
|
primaryJSON, err := json.Marshal(primaryDecision)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.db.Model(&DebateSessionDB{}).Where("id = ?", id).Updates(map[string]interface{}{
|
|
"final_decision": string(primaryJSON),
|
|
"status": DebateStatusCompleted,
|
|
}).Error
|
|
}
|
|
|
|
// DeleteSession deletes a debate session and all related data
|
|
func (s *DebateStore) DeleteSession(id string) error {
|
|
// Delete related data first
|
|
s.db.Where("session_id = ?", id).Delete(&DebateParticipant{})
|
|
s.db.Where("session_id = ?", id).Delete(&DebateMessage{})
|
|
s.db.Where("session_id = ?", id).Delete(&DebateVote{})
|
|
return s.db.Where("id = ?", id).Delete(&DebateSessionDB{}).Error
|
|
}
|
|
|
|
// AddParticipant adds a participant to a debate session
|
|
func (s *DebateStore) AddParticipant(participant *DebateParticipant) error {
|
|
if participant.ID == "" {
|
|
participant.ID = uuid.New().String()
|
|
}
|
|
if participant.Color == "" {
|
|
if color, ok := PersonalityColors[participant.Personality]; ok {
|
|
participant.Color = color
|
|
} else {
|
|
participant.Color = "#6B7280" // Default gray
|
|
}
|
|
}
|
|
return s.db.Create(participant).Error
|
|
}
|
|
|
|
// GetParticipants gets all participants for a debate session
|
|
func (s *DebateStore) GetParticipants(sessionID string) ([]*DebateParticipant, error) {
|
|
var participants []*DebateParticipant
|
|
err := s.db.Where("session_id = ?", sessionID).Order("speak_order").Find(&participants).Error
|
|
return participants, err
|
|
}
|
|
|
|
// AddMessage adds a message to a debate session
|
|
func (s *DebateStore) AddMessage(msg *DebateMessage) error {
|
|
if msg.ID == "" {
|
|
msg.ID = uuid.New().String()
|
|
}
|
|
if msg.Decision != nil {
|
|
data, err := json.Marshal(msg.Decision)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
msg.DecisionRaw = string(data)
|
|
}
|
|
return s.db.Create(msg).Error
|
|
}
|
|
|
|
// GetMessages gets all messages for a debate session
|
|
func (s *DebateStore) GetMessages(sessionID string) ([]*DebateMessage, error) {
|
|
var messages []*DebateMessage
|
|
err := s.db.Where("session_id = ?", sessionID).Order("round, created_at").Find(&messages).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parse decision JSON
|
|
for _, msg := range messages {
|
|
if msg.DecisionRaw != "" {
|
|
var decision DebateDecision
|
|
if json.Unmarshal([]byte(msg.DecisionRaw), &decision) == nil {
|
|
msg.Decision = &decision
|
|
}
|
|
}
|
|
}
|
|
return messages, nil
|
|
}
|
|
|
|
// GetMessagesByRound gets messages for a specific round
|
|
func (s *DebateStore) GetMessagesByRound(sessionID string, round int) ([]*DebateMessage, error) {
|
|
var messages []*DebateMessage
|
|
err := s.db.Where("session_id = ? AND round = ?", sessionID, round).Order("created_at").Find(&messages).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parse decision JSON
|
|
for _, msg := range messages {
|
|
if msg.DecisionRaw != "" {
|
|
var decision DebateDecision
|
|
if json.Unmarshal([]byte(msg.DecisionRaw), &decision) == nil {
|
|
msg.Decision = &decision
|
|
}
|
|
}
|
|
}
|
|
return messages, nil
|
|
}
|
|
|
|
// AddVote adds a vote to a debate session
|
|
func (s *DebateStore) AddVote(vote *DebateVote) error {
|
|
if vote.ID == "" {
|
|
vote.ID = uuid.New().String()
|
|
}
|
|
return s.db.Create(vote).Error
|
|
}
|
|
|
|
// GetVotes gets all votes for a debate session
|
|
func (s *DebateStore) GetVotes(sessionID string) ([]*DebateVote, error) {
|
|
var votes []*DebateVote
|
|
err := s.db.Where("session_id = ?", sessionID).Order("created_at").Find(&votes).Error
|
|
return votes, err
|
|
}
|
|
|
|
// DebateSessionWithDetails combines session with participants and messages
|
|
type DebateSessionWithDetails struct {
|
|
*DebateSession
|
|
Participants []*DebateParticipant `json:"participants"`
|
|
Messages []*DebateMessage `json:"messages"`
|
|
Votes []*DebateVote `json:"votes"`
|
|
}
|
|
|
|
// GetSessionWithDetails gets a session with all related data
|
|
func (s *DebateStore) GetSessionWithDetails(id string) (*DebateSessionWithDetails, error) {
|
|
session, err := s.GetSession(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
participants, err := s.GetParticipants(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
messages, err := s.GetMessages(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
votes, err := s.GetVotes(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &DebateSessionWithDetails{
|
|
DebateSession: session,
|
|
Participants: participants,
|
|
Messages: messages,
|
|
Votes: votes,
|
|
}, nil
|
|
}
|