Files
tipper/daemon/daemon.go
2026-07-21 05:29:37 +03:00

1263 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package daemon
import (
"encoding/json"
"errors"
"fmt"
"log"
"regexp"
"sort"
"sync"
"time"
"gordenko.dev/dima/fixme"
"gordenko.dev/dima/flashscore"
fsmodel "gordenko.dev/dima/flashscore/model"
"gordenko.dev/dima/onexbet"
"gordenko.dev/dima/pretty"
"gordenko.dev/dima/tipper/model"
"gordenko.dev/dima/web"
"gordenko.dev/dima/web/api"
"gordenko.dev/dima/ws"
"gordenko.dev/dima/ws/publisher"
)
const (
ChannelHandball = "handball"
ChannelFootball = "football"
ChannelTennis = "tennis"
// Флаги для округления тотала
roundUp = 0
roundDown = 1
// /SportFootball int64 = 1
MarketTotalH1 = 1
MarketTotal = 2
MarketHandicap = 3
SideOver = 1
SideUnder = 2
SideHome = 1
SideAway = 2
WSMessageTipsChanged = "tipsChanged"
// WSMessageTeamMatches Обновленный список матчей после синхронизации с flashscore
WSMessageTeamMatches = "teamMatches"
WSMessageWhyNot = "whyNot"
WSMessageInplayStatusChanged = "inplayStatusChanged"
)
var onexbetSportToFlashscoreSport = map[onexbet.OnexbetSport]int{
onexbet.Football: flashscore.Football,
onexbet.Handball: flashscore.Handball,
onexbet.Tennis: flashscore.Tennis,
}
var flashscoreSportToOnexbetSport = map[int]onexbet.OnexbetSport{
flashscore.Football: onexbet.Football,
flashscore.Handball: onexbet.Handball,
flashscore.Tennis: onexbet.Tennis,
}
var sportToTelegramChatID = map[int]string{
flashscore.Football: footballTelegramChatID,
flashscore.Handball: handballTelegramChatID,
flashscore.Tennis: tennisTelegramChatID,
}
type InplayStatusMessage struct {
MatchID string `json:"matchID"`
IsInplay bool `json:"isInplay"`
IsLinkedToOnexbet bool `json:"isLinkedToOnexbet"`
}
type TeamMatchesMessage struct {
SportID int `json:"sportID"`
Matches []TeamMatch `json:"matches"`
}
// flashscore sportID
func getChannel(sportID int) string {
switch sportID {
case flashscore.Football:
return ChannelFootball
case flashscore.Handball:
return ChannelHandball
case flashscore.Tennis:
return ChannelTennis
default:
panic(fmt.Sprintf("Bug: channel is undefined for the sportID %d", sportID))
}
}
type TipResult struct {
Status model.TipStatus
Result string
}
type StrategyOptions struct {
ID int64
NotifyInTelegram bool
IsChampAccepted func(string) bool
}
type TeamStrategy interface {
ID() int64
ShortName() string
// (report) notes, isInteresting
IsInteresting(*TeamMatch) (interface{}, bool)
// return -> comment why not,
GetTip(*TeamMatch, onexbet.TeamLiveMatchData) (string, *BetDetails, TipCode)
GetResult(model.Tip, onexbet.TeamMatchResult) *TipResult
IsNotifyInTelegram() bool
GetTelegramTipPattern() string
IsNeedToWatch(onexbet.LiveMatch) bool
}
type ChampRatingKey struct {
ChampID string
StrategyID int64
}
type ChampRating struct {
Won int
Lost int
}
func (s *ChampRating) GetAccuracy() (accuracy float64) {
// total = 100%
// won = x%
// x = (won*100) / total
total := s.Won + s.Lost
if total > 0 {
accuracy = float64(s.Won*100) / float64(total)
}
return
}
type FavoriteSide int
const (
FavoriteHome FavoriteSide = 1
FavoriteAway FavoriteSide = 2
)
type TeamMatch struct {
// COMMON
Score onexbet.Score `json:"score"`
ScoreByPeriods map[int]onexbet.Score `json:"scoreByPeriods"`
CurrentPeriod int `json:"currentPeriod"`
// TENNIS stats
TwoSetsBreakpoints int `json:"twoSetsBreakpoints"`
// Для стратегий где нужно определить доматчевого фаворита
// 0 - фаворит не определен
FavoriteSide FavoriteSide `json:"favoriteSide"`
MaxFavoritHandicapIn1stSet *onexbet.ParamOffer `json:"maxFavoritHandicapIn1stSet"`
// less than or equal 1.6 (для алгоритма 4 в теннисе)
Lte16FavoriteSide FavoriteSide `json:"lte16FavoriteSide"`
//IsTargetPriceIn2ndSetFound bool
// Football
Favorite string `json:"favorite,omitempty"`
// Handball
// Начальный тотал (по flashscore до матча)
BasicTotal float64 `json:"basicTotal"`
H1BasicTotal float64 `json:"h1BasicTotal"`
HomeGoalsPerMinute float64 `json:"homeGoalsPerMinute"`
AwayGoalsPerMinute float64 `json:"awayGoalsPerMinute"`
H2HGoalsPerMinute float64 `json:"h2hGoalsPerMinute"`
GamePace float64 `json:"-"`
GamePaceFrame int `json:"-"`
// Main
MatchID string `json:"matchID"`
Home fsmodel.Team `json:"home"`
Away fsmodel.Team `json:"away"`
Zone fsmodel.TeamZone `json:"zone"`
Champ fsmodel.TeamChamp `json:"champ"`
FullChampName string `json:"fullChampName"`
IsLinkedToOnexbet bool `json:"isLinkedToOnexbet"`
IsInplay bool `json:"isInplay"` // узнать - отписались от обновлений или нет?
SportID int `json:"sportID"`
Strategies map[int64]TeamStrategy `json:"-"`
StartTime int64 `json:"startTime"`
ParseTime time.Time `json:"parseTime"`
OnexbetMatchID string `json:"onexbetMatchID"`
OnexbetStatsMatchID string `json:"onexbetStatsMatchID"`
IsTeamsSwapped bool `json:"isTeamsSwapped"`
Notes []StrategyNotes `json:"notes"`
//TipTotalOver float64 `json:"tipTotalOver"`
//TipTotalUnder float64 `json:"tipTotalUnder"`
// Ожимаемый темп игры (голов в минуту). Начальный тотал / 60 минут
//BasicGoalsPerMinute float64 `json:"basicGoalsPerMinute"`
//BasicOverGoalsPerMinute float64 `json:"basicOverGoalsPerMinute"`
//BasicUnderGoalsPerMinute float64 `json:"basicUnderGoalsPerMinute"`
// Статистика для расчета
HomeTeamMatches []fsmodel.TeamMatch `json:"homeTeamMatches"`
AwayTeamMatches []fsmodel.TeamMatch `json:"awayTeamMatches"`
H2H []fsmodel.TeamMatch `json:"h2h"`
Odds flashscore.Odds `json:"odds"`
}
type StrategyNotes struct {
StrategyID int64 `json:"strategyID"`
Notes interface{} `json:"notes"`
}
type TipCode int
const (
Waiting TipCode = 0
Unwatch TipCode = 1
Bet TipCode = 2
)
type BetDetails struct {
Market int
Side int
Param string
Price float64
TelegramMessage string
}
/*
var tipTextAlgo100Pattern = `Сигнал # %d.
Будет ГОЛ!
Алгоритм 100
Первый тайм, Тотал Больше 0.5
Футбол. %s. %s
%s - %s
Коэф. %.3f`
*/
var tipResultTextPattern = `Сигнал # %d.
%s
%s
`
const (
EmptyTipStatus fixme.Code = "T1"
WrongTipStatus fixme.Code = "T2"
EmptyValue fixme.Code = "T3"
)
var errorMessages = map[fixme.Code]string{
EmptyTipStatus: "не указан статус",
WrongTipStatus: "неверный статус",
EmptyValue: "пустое значение недопустимо",
}
var fix = fixme.New(errorMessages)
type Options struct {
Logger *log.Logger
WsPublicServer *ws.PublicServer
FlashscoreAPIAddr string
OnexbetWsURL string
OnexbetAPIURL string
SportIDs []int
FlashscoreModel *fsmodel.Model
TipperModel *model.Model
}
type Daemon struct {
mutex sync.Mutex
fsAPIClient *api.Client
onexbetClient *ws.Client
logger *log.Logger
model *model.Model
wsServer *ws.PublicServer
publisher *publisher.PublicPublisher
onexbetAPIClient *api.Client
fsmodel *fsmodel.Model
champRatings map[int]map[ChampRatingKey]*ChampRating //map[int64]map[string]*ChampRating
//handballChampRatings map[ChampRatingKey]*ChampRating
// Отобаранные матчи по видам спорта
teamMatches map[int]map[string]*TeamMatch
onexbetMatchToTeamMatch map[string]*TeamMatch
sportStrategies map[int]map[int64]TeamStrategy
historyProcessors map[int]func(*TeamMatch)
sportIDs []int
unrecognizedOnexbetMatches map[int]map[string]bool
identifiedMatches map[string]bool // 1xbet MatchID => true
}
func New(opt Options) (*Daemon, error) {
s := new(Daemon)
s.logger = opt.Logger
s.sportIDs = opt.SportIDs
s.identifiedMatches = make(map[string]bool)
s.model = opt.TipperModel
s.fsmodel = opt.FlashscoreModel
// TELEGRAM, topChamps
/*
s.footballStrategies[11] = NewP1Over05(StrategyOptions{
ID: 11,
NotifyInTelegram: true,
IsChampAccepted: acceptedChamps(topChamps),
})
s.footballStrategies[22] = NewOver05(StrategyOptions{
ID: 22,
NotifyInTelegram: true,
IsChampAccepted: acceptedChamps(topChamps),
})
s.footballStrategies[33] = NewOver15(StrategyOptions{
ID: 33,
NotifyInTelegram: true,
IsChampAccepted: acceptedChamps(topChamps),
})
// Мои стратегии - ТМ для всех чемпионатов, кроме топовых
s.footballStrategies[701] = NewP1Under05(StrategyOptions{
ID: 701,
NotifyInTelegram: false,
IsChampAccepted: nonAcceptedChamps(topChamps),
})
s.footballStrategies[702] = NewUnder05(StrategyOptions{
ID: 702,
NotifyInTelegram: false,
IsChampAccepted: nonAcceptedChamps(topChamps),
})
s.footballStrategies[703] = NewUnder15(StrategyOptions{
ID: 703,
NotifyInTelegram: false,
IsChampAccepted: nonAcceptedChamps(topChamps),
})
*/
if opt.FlashscoreAPIAddr == "" {
return nil, errors.New("FlashscoreAPIAddr option is required")
}
var err error
s.fsAPIClient, err = api.NewClient(api.ClientOptions{
URL: opt.FlashscoreAPIAddr,
BuildRequest: api.BuildEnvelopeRequest,
})
if err != nil {
return nil, fmt.Errorf("api.NewClient(flashscore): %s", err)
}
s.onexbetClient, err = ws.NewClient(ws.ClientOptions{
URL: opt.OnexbetWsURL,
Logger: opt.Logger,
})
if err != nil {
return nil, fmt.Errorf("ws.NewClient(onexbet): %s", err)
}
s.onexbetClient.OnConnected(s.onOnexbetWsClientConnected)
s.onexbetClient.Handle(onexbet.WsMsgLiveMatches, s.onOnexbetTeamLiveMatches)
s.onexbetClient.Handle(onexbet.WsMsgTeamLiveMatchData, s.onOnexbetTeamLiveMatchData)
s.onexbetClient.Handle(onexbet.WsMsgTennisLiveMatchData, s.onOnexbetTeamLiveMatchData)
s.wsServer = opt.WsPublicServer
s.wsServer.OnConnected(s.onWsClientConnected)
s.wsServer.OnDisconnected(s.onWsClientDisconnected)
s.publisher, err = publisher.NewPublicPublisher(publisher.PublicPublisherOptions{
WsPublicServer: s.wsServer,
})
s.publisher.OnTopicSubscribeRequest(ChannelHandball, s.onWsSubscribeRequestToHandball)
s.publisher.OnTopicSubscribeRequest(ChannelFootball, s.onWsSubscribeRequestToFootball)
s.publisher.OnTopicSubscribeRequest(ChannelTennis, s.onWsSubscribeRequestToTennis)
s.onexbetAPIClient, err = api.NewClient(api.ClientOptions{
URL: opt.OnexbetAPIURL,
BuildRequest: api.BuildEnvelopeRequest,
})
if err != nil {
return nil, fmt.Errorf("api.NewClient(onexbet): %s", err)
}
s.teamMatches = make(map[int]map[string]*TeamMatch)
s.onexbetMatchToTeamMatch = make(map[string]*TeamMatch)
s.sportStrategies = make(map[int]map[int64]TeamStrategy)
s.unrecognizedOnexbetMatches = make(map[int]map[string]bool)
s.champRatings = make(map[int]map[ChampRatingKey]*ChampRating)
for _, sportID := range s.sportIDs {
s.teamMatches[sportID] = make(map[string]*TeamMatch)
s.sportStrategies[sportID] = make(map[int64]TeamStrategy)
s.unrecognizedOnexbetMatches[sportID] = make(map[string]bool)
s.champRatings[sportID] = make(map[ChampRatingKey]*ChampRating)
}
// Набор статистики по всем чемпионатам
// FOOTBALL
/*
s.sportStrategies[flashscore.Football][1] = NewP1Over05(StrategyOptions{
ID: 1,
NotifyInTelegram: true,
})
s.sportStrategies[flashscore.Football][2] = NewOver05(StrategyOptions{
ID: 2,
NotifyInTelegram: true,
})
s.sportStrategies[flashscore.Football][3] = NewOver15(StrategyOptions{
ID: 3,
NotifyInTelegram: true,
})
*/
s.sportStrategies[flashscore.Football][6] = NewFootballP1Over05(StrategyOptions{
ID: 6,
NotifyInTelegram: true,
})
s.sportStrategies[flashscore.Football][7] = NewFootballOver05(StrategyOptions{
ID: 7,
NotifyInTelegram: true,
})
s.sportStrategies[flashscore.Football][8] = NewFootballOver15(StrategyOptions{
ID: 8,
NotifyInTelegram: true,
})
// HANDBALL
s.sportStrategies[flashscore.Handball][4] = NewHandballFTOver(HandballStrategyOptions{
ID: 4,
NotifyInTelegram: true,
TotalDiff: 10,
FixateGamePaceEveryNMinutes: 5,
})
s.sportStrategies[flashscore.Handball][5] = NewHandballFTUnder(HandballStrategyOptions{
ID: 5,
NotifyInTelegram: true,
TotalDiff: 10,
FixateGamePaceEveryNMinutes: 5,
})
// HANDBALL
s.sportStrategies[flashscore.Handball][11] = NewHandballP1Over(HandballStrategyOptions{
ID: 11,
NotifyInTelegram: true,
TotalDiff: 7,
FixateGamePaceEveryNMinutes: 5,
})
s.sportStrategies[flashscore.Handball][12] = NewHandballP1Under(HandballStrategyOptions{
ID: 12,
NotifyInTelegram: true,
TotalDiff: 7,
FixateGamePaceEveryNMinutes: 5,
})
/*
s.sportStrategies[flashscore.Handball][13] = NewHandballP1Over(HandballStrategyOptions{
ID: 13,
NotifyInTelegram: true,
TotalDiff: 5,
FixateGamePaceEveryNMinutes: 5,
})
s.sportStrategies[flashscore.Handball][14] = NewHandballP1Under(HandballStrategyOptions{
ID: 14,
NotifyInTelegram: true,
TotalDiff: 5,
FixateGamePaceEveryNMinutes: 5,
})
*/
// TENNIS
s.sportStrategies[flashscore.Tennis][15] = NewTennis3rdSetTotalUnder(StrategyOptions{
ID: 15,
NotifyInTelegram: true,
})
s.sportStrategies[flashscore.Tennis][16] = NewTennis2ndSetPlusHandicap(StrategyOptions{
ID: 16,
NotifyInTelegram: true,
})
s.sportStrategies[flashscore.Tennis][17] = NewTennis2ndSetPlusHandicapAny(StrategyOptions{
ID: 17,
NotifyInTelegram: true,
})
s.sportStrategies[flashscore.Tennis][18] = NewTennis2ndSetFavoriteHandicap(StrategyOptions{
ID: 18,
NotifyInTelegram: true,
})
s.historyProcessors = make(map[int]func(*TeamMatch))
s.historyProcessors[flashscore.Handball] = HandballHistoryProcessor
// Формируем рейтинг для футбольных стратегий
for sportID, sportStrategies := range s.sportStrategies {
champRatings := s.champRatings[sportID]
for strategyID := range sportStrategies {
var stats []model.StrategyChampStats
stats, err = s.model.ListStrategyChampStats(strategyID)
if err != nil {
return nil, fmt.Errorf("model.ListStrategyChampStats(%d): %s",
strategyID, err)
}
//buf, _ := json.MarshalIndent(stats, "", " ")
//s.logger.Printf("Stats (%d):\n%s\n\n", strategyID, buf)
for _, champ := range stats {
key := ChampRatingKey{
ChampID: champ.ChampID,
StrategyID: strategyID,
}
champRatings[key] = &ChampRating{
Won: champ.Won,
Lost: champ.Lost,
}
}
}
}
return s, nil
}
func (s *Daemon) ListRatedChamps(state *web.State, reply *web.Reply) (err error) {
var sportID int
state.Val("sportID", &sportID)
s.mutex.Lock()
defer s.mutex.Unlock()
ratings, ok := s.champRatings[sportID]
if !ok {
return
}
str := ""
type ChampAndRating struct {
ChampID string
StrategyID int64
Won int
Lost int
Accuracy float64
}
var sorted []ChampAndRating
for key, rating := range ratings {
//if (rating.Won+rating.Lost) >= 6 && rating.GetAccuracy() >= 75 {
if rating.GetAccuracy() >= 70 {
sorted = append(sorted, ChampAndRating{
ChampID: key.ChampID,
StrategyID: key.StrategyID,
Won: rating.Won,
Lost: rating.Lost,
Accuracy: rating.GetAccuracy(),
})
}
}
sort.Slice(sorted, func(i, j int) bool {
a := sorted[i]
b := sorted[j]
if a.StrategyID == b.StrategyID {
return a.Accuracy < b.Accuracy
}
return a.StrategyID < b.StrategyID
})
for _, champ := range sorted {
str += fmt.Sprintf("<tr><td>%s (стратегия %d):</td><td>won=%d, lost=%d, accuracy=%.2f</td></tr>", champ.ChampID, champ.StrategyID, champ.Won, champ.Lost, champ.Accuracy)
}
//reply.SetHeader("Content-Type", "text/pain")
reply.WriteString(`<table style="border-spacing: 10px; border: 0px;">` + str + `</table>`)
return
}
func (s *Daemon) TryLoadAndSetTipResults() {
tips, err := s.model.ListWaitingTips()
if err != nil {
err = fmt.Errorf("ListWaitingTips: %s", err)
return
}
s.logger.Println(pretty.PSprintln("Waiting Tips", tips))
// Час назад
//aHourAgo := time.Now().Unix() - 3600
for _, tip := range tips {
// Если матч начался менее часа назад - пропускаем.
//if tip.TipTime > aHourAgo {
// continue
//}
err = s.tryLoadAndSetTipResult(tip)
if err != nil {
s.logger.Printf("trySetTipResult: %s; tipID=%d", err, tip.TipID)
}
}
}
func (s *Daemon) Run() {
go s.flashscoreLoader()
go s.tipResultsLoader()
go s.oldMatchesRemover()
}
func (s *Daemon) oldMatchesRemover() {
interval := 30 * time.Minute
ticker := time.NewTicker(interval)
for {
select {
case <-ticker.C:
s.removeOldMatches()
}
}
}
func (s *Daemon) tipResultsLoader() {
interval := 20 * time.Minute
ticker := time.NewTicker(interval)
s.TryLoadAndSetTipResults()
for {
select {
case <-ticker.C:
s.TryLoadAndSetTipResults()
}
}
}
func (s *Daemon) flashscoreLoader() {
interval := 1 * time.Hour
ticker := time.NewTicker(interval)
for _, sportID := range s.sportIDs {
err := s.listUpcomingMatchReports(sportID)
if err != nil {
s.logger.Printf("listUpcomingMatchReports(sportID=%d): %s\n",
sportID, err)
}
}
s.onexbetClient.ConnectAsync()
for {
select {
case <-ticker.C:
for _, sportID := range s.sportIDs {
err := s.listUpcomingMatchReports(sportID)
if err != nil {
s.logger.Printf("listUpcomingMatchReports(sportID=%d): %s\n",
sportID, err)
}
}
}
}
}
func (s *Daemon) removeOldMatches() {
s.mutex.Lock()
defer s.mutex.Unlock()
a2HoursAgo := time.Now().Unix() - 7200
for sportID, sportMatches := range s.teamMatches {
var count int
for _, match := range sportMatches {
_, isInInplay := s.onexbetMatchToTeamMatch[match.OnexbetMatchID]
if match.StartTime < a2HoursAgo && !isInInplay {
// Если матч стартовал более 2 часов назад и не в Inplay - удаляем
delete(sportMatches, match.MatchID)
//delete(s.identifiedMatches, match.OnexbetMatchID)
count++
}
}
// Отправляем уведомления
if count > 0 {
s.publisher.Publish(
getChannel(sportID),
WSMessageTeamMatches,
s.getTeamMatchesMessage(sportID),
)
}
}
}
func (s *Daemon) listUpcomingMatchReports(sportID int) (err error) {
var buf []byte
err = s.fsAPIClient.Call(api.CallReq{
FuncName: "listUpcomingMatchReports",
In: sportID,
Out: &buf,
})
if err != nil {
return
}
var reports []flashscore.UpcomingMatchReport
err = json.Unmarshal(buf, &reports)
if err != nil {
return
}
now := time.Now().Unix() - 10800
s.mutex.Lock()
defer s.mutex.Unlock()
sportStrategies := s.sportStrategies[sportID]
fmt.Printf("Sport strategies qty: %d\n", len(sportStrategies))
teamMatches := s.teamMatches[sportID]
// Сохраняем в этот словарь все matchID из reports, чтобы сделав проход по teamMatches,
// обнаружить матчи, которых больше нет в reports
//checkedMatchIDs := make(map[string]bool)
// Синхронизация:
// - матчи из teamMatches, которые по времени начались - пропускаем
// - матчи из reports, которые по времени начались - пропускаем
// - матчи, которых нет в teamMatches - добавляем
// - матчи, которые есть в teamMatches (и еще не начались), но нет в reports - удаляем
// - матчи, которые есть и там и там, но не начались - обнуляем список стратегий
// и прогоняем с нуля. Если ни одной стратегии не удовлетворяем - удаляем
// Первый проход по reports
for _, report := range reports {
//checkedMatchIDs[report.MatchID] = true
if report.StartTime <= now {
// матч уже начался - пропускаем
s.logger.Printf("match already started: %d < now %d\n", report.StartTime, now)
continue
}
match, ok := teamMatches[report.MatchID]
if ok {
if match.StartTime <= now {
// матч уже начался - пропускаем
s.logger.Printf("match already started: %d < now %d\n", match.StartTime, now)
continue
}
if match.IsLinkedToOnexbet {
// матч уже в линии 1xBet и распознан - пропускаем
s.logger.Printf("match %s already linked\n", match.MatchID)
continue
}
// Обнуляем стратегии чтобы пересканировать заново
match.Strategies = make(map[int64]TeamStrategy)
match.ParseTime = report.ParseTime
match.HomeTeamMatches = report.HomeTeamMatches
match.AwayTeamMatches = report.AwayTeamMatches
match.H2H = report.H2H
match.Odds = report.Odds
match.Notes = nil
} else {
// Чтобы звездочками отметить матчи, которые не были ни разу распознаны
home, err := s.fsmodel.GetTeam(report.Home.TeamID)
if err != nil {
s.logger.Printf("fsmodel.GetTeam: %s; teamID=%s", err, report.Home.TeamID)
} else {
report.Home.WasLinked = home.WasLinked
}
away, err := s.fsmodel.GetTeam(report.Away.TeamID)
if err != nil {
s.logger.Printf("fsmodel.GetTeam: %s; teamID=%s", err, report.Away.TeamID)
} else {
report.Away.WasLinked = away.WasLinked
}
// Добавляем новый матч
match = &TeamMatch{
MatchID: report.MatchID,
Home: report.Home,
Away: report.Away,
Zone: report.Zone,
Champ: report.Champ,
FullChampName: report.FullChampName,
StartTime: report.StartTime,
ParseTime: report.ParseTime,
SportID: sportID,
Strategies: make(map[int64]TeamStrategy),
HomeTeamMatches: report.HomeTeamMatches,
AwayTeamMatches: report.AwayTeamMatches,
H2H: report.H2H,
Odds: report.Odds,
}
teamMatches[report.MatchID] = match
s.logger.Printf("match %s added\n", match.MatchID)
}
historyProcessor := s.historyProcessors[sportID]
if historyProcessor != nil {
historyProcessor(match)
}
for _, strategy := range sportStrategies {
// Сперва проверяем не было ли уже прогнозов на этот матч и эту стратегию
var hasTip bool
hasTip, err = s.model.HasTip(match.MatchID, strategy.ID())
if err != nil {
s.logger.Printf("model.HasTip: %s\n", err)
} else {
if !hasTip {
notes, isInteresting := strategy.IsInteresting(match)
if isInteresting {
match.Strategies[strategy.ID()] = strategy
match.Notes = append(match.Notes, StrategyNotes{
StrategyID: strategy.ID(),
Notes: notes,
})
}
}
}
}
if len(match.Strategies) == 0 {
// матч никому не интересен - удаляем
s.logger.Printf("match %s not interesting -> remove\n", match.MatchID)
delete(teamMatches, report.MatchID)
}
}
/*
// Второй проход по teamMatches
for matchID, match := range teamMatches {
if checkedMatchIDs[matchID] {
continue
}
// Матча не было в reports - какндидат на удаление
if match.StartTime <= now {
// матч уже начался - пропускаем
continue
}
if match.IsLinkedToOnexbet {
// матч уже в линии 1xBet и распознан - пропускаем
continue
}
delete(teamMatches, matchID)
}
*/
s.publisher.Publish(getChannel(sportID),
WSMessageTeamMatches, s.getTeamMatchesMessage(sportID))
return
}
func (s *Daemon) getTeamMatchesMessage(sportID int) (msg TeamMatchesMessage) {
teamsMatches, ok := s.teamMatches[sportID]
if !ok {
panic(fmt.Sprintf("Bug: getTeamMatchesMessage of unknown sportID %d\n", sportID))
}
msg.SportID = sportID
for _, match := range teamsMatches {
msg.Matches = append(msg.Matches, *match)
}
return
}
func (s *Daemon) tryLoadAndSetTipResult(tip model.Tip) (err error) {
if tip.OnexbetStatsMatchID == "" {
err = fmt.Errorf("Empty OnexbetStatsMatchID")
return
}
var matchResult onexbet.TeamMatchResult
if tip.SportID == flashscore.Tennis {
matchResult, err = onexbet.GetTennisMatchResult(tip.OnexbetStatsMatchID)
if err != nil {
err = fmt.Errorf("onexbet.GetTeamMatchResult: %s", err)
return
}
} else {
matchResult, err = onexbet.GetTeamMatchResult(tip.OnexbetStatsMatchID)
if err != nil {
err = fmt.Errorf("onexbet.GetTeamMatchResult: %s", err)
return
}
}
s.logger.Printf("%s result: %s\n", tip.OnexbetStatsMatchID, pretty.Sprint(matchResult))
return s.trySetTipResult(tip, matchResult)
}
func swapTeamMatchResult(r onexbet.TeamMatchResult) onexbet.TeamMatchResult {
r.AwayPoints, r.HomePoints = r.HomePoints, r.AwayPoints
for idx, p := range r.ScoreByPeriods {
p.AwayPoints, p.HomePoints = p.HomePoints, p.AwayPoints
r.ScoreByPeriods[idx] = p
}
return r
}
func (s *Daemon) trySetTipResult(tip model.Tip, matchResult onexbet.TeamMatchResult) (err error) {
sportStrategies, ok := s.sportStrategies[int(tip.SportID)]
if !ok {
err = fmt.Errorf("No strategies found for the sportID %d", tip.SportID)
return
}
strategy, ok := sportStrategies[tip.StrategyID]
if !ok {
s.logger.Printf("Unknown strategyID %d", tip.StrategyID)
return
}
if tip.IsTeamsSwapped {
matchResult = swapTeamMatchResult(matchResult)
}
result := strategy.GetResult(tip, matchResult)
if result == nil {
err = fmt.Errorf("strategy.GetResult returns nil")
return
}
//s.logger.Printf(pretty.PSprintln)
err = s.model.SetTipResult(model.SetTipResultReq{
TipID: tip.TipID,
Status: result.Status,
Result: result.Result,
})
if err != nil {
err = fmt.Errorf("model.SetTipResult: %s", err)
return
}
s.notifyAboutTipResult(tip, result, strategy)
return
}
func (s *Daemon) notifyAboutTipResult(tip model.Tip, result *TipResult, strategy TeamStrategy) (err error) {
var outcome string
switch result.Status {
case model.Won:
outcome = "Выигрыш"
case model.Lost:
outcome = "Проигрыш"
case model.Void:
outcome = "Возврат"
}
msg := fmt.Sprintf(tipResultTextPattern, tip.TipID, result.Result, outcome)
// Обновляем рейтинг чемпионатов
key := ChampRatingKey{
ChampID: tip.ChampID,
StrategyID: tip.StrategyID,
}
champRatings, ok := s.champRatings[int(tip.SportID)]
if ok {
rating, ok := champRatings[key]
if !ok {
rating = &ChampRating{}
champRatings[key] = rating
}
if result.Status == model.Won {
rating.Won++
} else if result.Status == model.Lost {
rating.Lost++
}
}
if strategy.IsNotifyInTelegram() {
// дополнительная проверка на isRated. У тенниса нет рейтингов по чемпионатам,
// поэтому проверка более хитрая.
var ok bool
switch tip.SportID {
case flashscore.Tennis:
ok = true
case flashscore.Handball, flashscore.Football:
if tip.IsRated {
ok = true
}
}
if ok {
telegramChatID, hasChat := sportToTelegramChatID[int(tip.SportID)]
if hasChat {
err = sendMessageToTelegramGroup(telegramChatID, msg)
if err != nil {
s.logger.Printf("send tip result to Telegram: %s\n", err)
}
} else {
s.logger.Printf("Sport %d hasn't telegram chat\n", tip.SportID)
}
}
}
s.publisher.Publish(getChannel(int(tip.SportID)), WSMessageTipsChanged, nil)
return
}
func (s *Daemon) SetTipResultManual(in model.SetTipResultReq) (err error) {
tip, err := s.model.GetTip(in.TipID)
if err != nil {
return
}
if tip == nil {
s.logger.Printf("setTipResultManual: tipID=%d not found in db\n", in.TipID)
return
}
switch in.Status {
case 0:
err = fix.Field(EmptyTipStatus, "status")
return
case model.Won, model.Lost, model.Void:
// pass
default:
err = fix.Field(WrongTipStatus, "status")
return
}
err = s.model.SetTipResult(in)
if err != nil {
return
}
// Если стратегии не найдены - сообщение в телеграм отправлено не будет
sportStrategies, ok := s.sportStrategies[int(tip.SportID)]
if !ok {
err = fmt.Errorf("No strategies found for the sportID %d", tip.SportID)
return
}
strategy, ok := sportStrategies[tip.StrategyID]
if !ok {
s.logger.Printf("Unknown strategyID %d", tip.StrategyID)
return
}
s.notifyAboutTipResult(
*tip,
&TipResult{Status: in.Status, Result: in.Result},
strategy,
)
return
}
func (s *Daemon) onWsSubscribeRequestToHandball(conn *ws.Conn, channel, arg string) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.logger.Printf("ws client subscribes to channel %q\n", channel)
conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Handball))
s.publisher.SubscribeTo(conn, channel)
}
func (s *Daemon) onWsSubscribeRequestToFootball(conn *ws.Conn, channel, arg string) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.logger.Printf("ws client subscribes to channel %q\n", channel)
conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Football))
s.publisher.SubscribeTo(conn, channel)
}
func (s *Daemon) onWsSubscribeRequestToTennis(conn *ws.Conn, channel, arg string) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.logger.Printf("ws client subscribes to channel %q\n", channel)
conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Tennis))
s.publisher.SubscribeTo(conn, channel)
}
// func (s *Daemon) onWsSubscribeRequest(conn *ws.Conn, channel string) {
// s.mutex.Lock()
// defer s.mutex.Unlock()
// s.logger.Printf("ws client subscribes to channel %q\n", channel)
// switch channel {
// case ChannelHandball:
// conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Handball))
// case ChannelFootball:
// conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Football))
// case ChannelTennis:
// conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Tennis))
// }
// s.publisher.SubscribeTo(conn, channel)
// }
func (s *Daemon) onWsClientConnected(conn *ws.Conn) {
//s.mutex.Lock()
//defer s.mutex.Unlock()
s.logger.Printf("ws client connected\n")
//for _, sportID := range s.sportIDs {
// conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(sportID))
//}
//s.logger.Printf("team matches sent\n")
//s.publisher.SubscribeTo(conn, Channel)
}
func (s *Daemon) onWsClientDisconnected(conn *ws.Conn) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.logger.Printf("ws client disconnected\n")
//s.publisher.UnsubscribeConn(conn)
}
// Регулярка для вырезания буквы W из названия команды.
// Поиск на 1xBet не дает результатов если в названии есть W, например Ajax W
var reW = regexp.MustCompile(`\bW\b`)
type SearchMatchCandidatesIn struct {
SportID int `json:"sportID"`
Home string `json:"home"`
Away string `json:"away"`
}
func (s *Daemon) SearchMatchCandidates(in SearchMatchCandidatesIn) (matches []onexbet.MatchCandidate, err error) {
in.Home = reW.ReplaceAllString(in.Home, "")
in.Away = reW.ReplaceAllString(in.Away, "")
switch in.SportID {
case flashscore.Football:
return onexbet.SearchMatchCandidates(in.Home, in.Away, onexbet.Football)
case flashscore.Handball:
return onexbet.SearchMatchCandidates(in.Home, in.Away, onexbet.Handball)
case flashscore.Tennis:
return onexbet.SearchMatchCandidates(in.Home, in.Away, onexbet.Tennis)
}
return
}
type SearchMatchCandidatesByTeamNameIn struct {
SportID int `json:"sportID"`
TeamName string `json:"teamName"`
}
func (s *Daemon) SearchMatchCandidatesByTeamName(in SearchMatchCandidatesByTeamNameIn) (matches []onexbet.MatchCandidate, err error) {
in.TeamName = reW.ReplaceAllString(in.TeamName, "")
return onexbet.SearchTeamCandidates(in.TeamName, flashscoreSportToOnexbetSport[in.SportID])
}
type AddTeamsAliasesIn struct {
SportID int
Home fsmodel.TeamAlias
Away fsmodel.TeamAlias
}
func (s *Daemon) AddTeamsAliases(in AddTeamsAliasesIn) (err error) {
err = s.fsmodel.AddTeamAlias(in.Home)
if err != nil {
apierr, ok := err.(fixme.Field)
if !ok {
return
}
if apierr.Code != fsmodel.Duplicate {
return
}
err = nil
}
err = s.fsmodel.AddTeamAlias(in.Away)
if err != nil {
apierr, ok := err.(fixme.Field)
if !ok {
return
}
if apierr.Code != fsmodel.Duplicate {
return
}
err = nil
}
s.mutex.Lock()
defer s.mutex.Unlock()
// Сбрасываем кэш нераспознанных 1xBet матчей
s.unrecognizedOnexbetMatches[in.SportID] = make(map[string]bool)
return
}
func (s *Daemon) ListSportTips(sportID int) (tips []model.Tip, err error) {
tips, err = s.model.ListSportTips(sportID)
if err != nil {
return
}
for idx, tip := range tips {
strategies, foundSport := s.sportStrategies[int(tip.SportID)]
if foundSport {
strategy, has := strategies[tip.StrategyID]
if has {
tip.Strategy = strategy.ShortName()
tips[idx] = tip
}
}
}
return
}