Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Contribute to GitLab
Sign in
Toggle navigation
M
MBetterc
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Mbetter
MBetterc
Commits
03d2e1c8
Commit
03d2e1c8
authored
May 14, 2026
by
Your Name
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Fix settlement and report sync accounting
parent
b57de374
Changes
4
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
205 additions
and
114 deletions
+205
-114
client.py
mbetterclient/api_client/client.py
+5
-4
games_thread.py
mbetterclient/core/games_thread.py
+68
-110
test_reports_sync_summary.py
test_reports_sync_summary.py
+48
-0
test_settlement_consistency.py
test_settlement_consistency.py
+84
-0
No files found.
mbetterclient/api_client/client.py
View file @
03d2e1c8
...
...
@@ -1746,6 +1746,7 @@ class ReportsSyncResponseHandler(ResponseHandler):
total_payin
=
0.0
total_payout
=
0.0
total_bets
=
0
unique_match_ids
=
set
()
for
bet
in
bets
:
# Ensure bet_details are loaded (handle lazy loading)
...
...
@@ -1756,15 +1757,15 @@ class ReportsSyncResponseHandler(ResponseHandler):
if
bet_details
:
total_payin
+=
sum
(
d
.
amount
for
d
in
bet_details
)
total_bets
+=
len
(
bet_details
)
# Add win amounts for winning bets (result is 'win', not 'won')
total_payout
+=
sum
(
d
.
win_amount
for
d
in
bet_details
if
d
.
result
==
'win'
)
unique_match_ids
.
update
(
d
.
match_id
for
d
in
bet_details
if
getattr
(
d
,
'match_id'
,
None
)
is
not
None
)
return
{
'total_payin'
:
float
(
total_payin
),
'total_payout'
:
float
(
total_payout
),
'net_profit'
:
float
(
total_payin
-
total_payout
),
'total_bets'
:
total_bets
,
'total_matches'
:
len
(
stat
s
)
'total_matches'
:
len
(
unique_match_id
s
)
}
def
_calculate_backoff_time
(
self
,
retry_count
:
int
)
->
int
:
...
...
@@ -3183,4 +3184,4 @@ class APIClient(ThreadedComponent):
return
shlex
.
join
(
cmd_parts
)
except
AttributeError
:
# Fallback for Python < 3.8
return
' '
.
join
(
shlex
.
quote
(
arg
)
for
arg
in
cmd_parts
)
\ No newline at end of file
return
' '
.
join
(
shlex
.
quote
(
arg
)
for
arg
in
cmd_parts
)
mbetterclient/core/games_thread.py
View file @
03d2e1c8
...
...
@@ -999,65 +999,75 @@ class GamesThread(ThreadedComponent):
session: Database session
"""
try
:
# Get all pending bets for this match
pending_bets
=
session
.
query
(
BetDetailModel
)
.
filter
(
BetDetailModel
.
match_id
==
match
.
id
,
BetDetailModel
.
result
==
'pending'
)
.
all
()
if
not
pending_bets
:
logger
.
debug
(
f
"No pending bets to resolve for match {match.id}"
)
return
logger
.
info
(
f
"🔍 Resolving {len(pending_bets)} pending bets for match {match.id}"
)
# Determine winning outcomes
self
.
_settle_match_bets_from_match_state
(
match
,
session
,
pending_only
=
True
)
except
Exception
as
e
:
logger
.
error
(
f
"Failed to resolve bets for match {match.id}: {e}"
)
import
traceback
logger
.
error
(
f
"Traceback: {traceback.format_exc()}"
)
def
_settle_match_bets_from_match_state
(
self
,
match
:
MatchModel
,
session
,
pending_only
:
bool
=
True
):
"""Canonical settlement for one completed match using stored match state.
This is the single recovery/finalization path used after the primary
extraction flow has already persisted `match.result`, `match.winning_outcomes`,
and `match.under_over_result`.
"""
try
:
query
=
session
.
query
(
BetDetailModel
)
.
filter
(
BetDetailModel
.
match_id
==
match
.
id
)
if
pending_only
:
query
=
query
.
filter
(
BetDetailModel
.
result
==
'pending'
)
else
:
query
=
query
.
filter
(
BetDetailModel
.
result
!=
'cancelled'
)
bet_rows
=
query
.
all
()
if
not
bet_rows
:
logger
.
debug
(
f
"No bet rows to settle for match {match.id}"
)
return
(
0
,
0
)
winning_outcomes
=
[]
if
match
.
winning_outcomes
:
try
:
winning_outcomes
=
json
.
loads
(
match
.
winning_outcomes
)
except
(
json
.
JSONDecodeError
,
TypeError
):
pass
# Add under_over_result if set
if
match
.
under_over_result
:
winning_outcomes
.
append
(
match
.
under_over_result
)
# Add main result if set
if
match
.
result
:
if
isinstance
(
match
.
winning_outcomes
,
str
):
winning_outcomes
=
json
.
loads
(
match
.
winning_outcomes
)
elif
isinstance
(
match
.
winning_outcomes
,
list
):
winning_outcomes
=
list
(
match
.
winning_outcomes
)
except
(
json
.
JSONDecodeError
,
TypeError
)
as
exc
:
logger
.
warning
(
f
"Failed to parse winning_outcomes for match {match.id}: {exc}"
)
if
match
.
result
and
match
.
result
not
in
winning_outcomes
:
winning_outcomes
.
append
(
match
.
result
)
logger
.
info
(
f
"🔍 Winning outcomes for match {match.id}: {winning_outcomes}"
)
under_over_outcome
=
match
.
under_over_result
winning_set
=
{
outcome
for
outcome
in
winning_outcomes
if
outcome
and
outcome
not
in
[
'UNDER'
,
'OVER'
]}
logger
.
info
(
f
"🔍 Canonical settlement for match {match.id}: under_over_result={under_over_outcome}, "
f
"result={match.result}, result_winners={sorted(winning_set)}"
)
resolved_win
=
0
resolved_lost
=
0
for
bet
in
pending_bets
:
if
bet
.
outcome
in
winning_outcomes
:
# Winner
try
:
coefficient
=
self
.
_get_outcome_coefficient_for_bet
(
match
.
id
,
bet
.
outcome
,
session
)
bet
.
result
=
'win'
bet
.
win_amount
=
bet
.
amount
*
coefficient
resolved_win
+=
1
logger
.
info
(
f
"✅ Bet {bet.id} ({bet.outcome}) resolved as WIN with amount {bet.win_amount:.2f}"
)
except
Exception
as
coeff_e
:
logger
.
error
(
f
"Failed to get coefficient for {bet.outcome}: {coeff_e}"
)
bet
.
result
=
'win'
bet
.
win_amount
=
bet
.
amount
# Fallback to 1x coefficient
resolved_win
+=
1
for
bet
in
bet_rows
:
is_under_over_bet
=
bet
.
outcome
in
[
'UNDER'
,
'OVER'
]
is_winner
=
(
bet
.
outcome
==
under_over_outcome
)
if
is_under_over_bet
else
(
bet
.
outcome
in
winning_set
)
if
is_winner
:
coefficient
=
self
.
_get_outcome_coefficient
(
match
.
id
,
bet
.
outcome
,
session
)
bet
.
result
=
'win'
bet
.
win_amount
=
bet
.
amount
*
coefficient
resolved_win
+=
1
logger
.
info
(
f
"✅ Settled bet {bet.id} ({bet.outcome}) as WIN with amount {bet.win_amount:.2f}"
)
else
:
# Loser
bet
.
result
=
'lost'
bet
.
win_amount
=
0.0
resolved_lost
+=
1
logger
.
info
(
f
"❌ Bet {bet.id} ({bet.outcome}) resolved as LOST"
)
logger
.
info
(
f
"✅ Resolved {len(pending_bets)} bets for match {match.id}: {resolved_win} wins, {resolved_lost} losses"
)
except
Exception
as
e
:
logger
.
error
(
f
"Failed to resolve bets for match {match.id}: {e}"
)
import
traceback
logger
.
error
(
f
"Traceback: {traceback.format_exc()}"
)
logger
.
info
(
f
"❌ Settled bet {bet.id} ({bet.outcome}) as LOST"
)
logger
.
info
(
f
"✅ Settled {len(bet_rows)} bets for match {match.id}: {resolved_win} wins, {resolved_lost} losses"
)
return
(
resolved_win
,
resolved_lost
)
except
Exception
as
exc
:
logger
.
error
(
f
"Failed canonical settlement for match {match.id}: {exc}"
)
raise
def
_get_outcome_coefficient_for_bet
(
self
,
match_id
:
int
,
outcome
:
str
,
session
)
->
float
:
"""Get coefficient for a specific outcome from match outcomes.
...
...
@@ -1197,17 +1207,16 @@ class GamesThread(ThreadedComponent):
try
:
session
=
self
.
db_manager
.
get_session
()
try
:
# Check for any remaining pending bets
pending_bets
=
session
.
query
(
BetDetailModel
)
.
filter
(
pending_count
=
session
.
query
(
BetDetailModel
)
.
filter
(
BetDetailModel
.
match_id
==
match_id
,
BetDetailModel
.
result
==
'pending'
)
.
all
()
)
.
count
()
if
not
pending_
bets
:
if
not
pending_
count
:
logger
.
debug
(
f
"All bets already resolved for match {match_id}"
)
return
logger
.
warning
(
f
"⚠️ [SAFETY NET] Found {
len(pending_bets)
} pending bets for completed match {match_id} - forcing resolution"
)
logger
.
warning
(
f
"⚠️ [SAFETY NET] Found {
pending_count
} pending bets for completed match {match_id} - forcing resolution"
)
# Get match details for resolution
match
=
session
.
query
(
MatchModel
)
.
filter_by
(
id
=
match_id
)
.
first
()
...
...
@@ -1215,60 +1224,9 @@ class GamesThread(ThreadedComponent):
logger
.
error
(
f
"[SAFETY NET] Match {match_id} not found - cannot resolve bets"
)
return
# Parse winning outcomes from match
winning_outcomes
=
[]
if
match
.
winning_outcomes
:
try
:
winning_outcomes
=
json
.
loads
(
match
.
winning_outcomes
)
logger
.
info
(
f
"[SAFETY NET] Match winning outcomes: {winning_outcomes}"
)
except
(
json
.
JSONDecodeError
,
TypeError
)
as
e
:
logger
.
warning
(
f
"[SAFETY NET] Failed to parse winning_outcomes: {e}"
)
# Get under/over result
under_over_result
=
match
.
under_over_result
if
match
else
None
logger
.
info
(
f
"[SAFETY NET] Match under_over_result: {under_over_result}"
)
# Resolve each pending bet
resolved_win
=
0
resolved_lost
=
0
for
bet
in
pending_bets
:
is_winner
=
False
# Check if bet outcome is in winning outcomes
if
bet
.
outcome
in
winning_outcomes
:
is_winner
=
True
logger
.
info
(
f
"[SAFETY NET] Bet {bet.id} ({bet.outcome}) matches winning outcome"
)
# Check if bet is the under/over winner
elif
bet
.
outcome
==
under_over_result
:
is_winner
=
True
logger
.
info
(
f
"[SAFETY NET] Bet {bet.id} ({bet.outcome}) matches under_over_result"
)
# Check if bet outcome matches the main result
elif
bet
.
outcome
==
result
:
is_winner
=
True
logger
.
info
(
f
"[SAFETY NET] Bet {bet.id} ({bet.outcome}) matches main result"
)
if
is_winner
:
# Winner - calculate win amount
try
:
coefficient
=
self
.
_get_outcome_coefficient
(
match_id
,
bet
.
outcome
,
session
)
bet
.
result
=
'win'
bet
.
win_amount
=
bet
.
amount
*
coefficient
resolved_win
+=
1
logger
.
info
(
f
"✅ [SAFETY NET] Force-resolved bet {bet.id} ({bet.outcome}) to WIN with amount {bet.win_amount:.2f}"
)
except
Exception
as
coeff_e
:
logger
.
error
(
f
"[SAFETY NET] Failed to get coefficient for {bet.outcome}: {coeff_e}"
)
bet
.
result
=
'win'
bet
.
win_amount
=
bet
.
amount
# Fallback to 1x coefficient
resolved_win
+=
1
else
:
# Loser
bet
.
result
=
'lost'
resolved_lost
+=
1
logger
.
info
(
f
"❌ [SAFETY NET] Force-resolved bet {bet.id} ({bet.outcome}) to LOST"
)
resolved_win
,
resolved_lost
=
self
.
_settle_match_bets_from_match_state
(
match
,
session
,
pending_only
=
True
)
session
.
commit
()
logger
.
info
(
f
"✅ [SAFETY NET] Force-resolved {
len(pending_bets)
} pending bets for match {match_id}: {resolved_win} wins, {resolved_lost} losses"
)
logger
.
info
(
f
"✅ [SAFETY NET] Force-resolved {
pending_count
} pending bets for match {match_id}: {resolved_win} wins, {resolved_lost} losses"
)
finally
:
session
.
close
()
...
...
@@ -4205,8 +4163,8 @@ class GamesThread(ThreadedComponent):
logger
.
warning
(
f
"Match {match_id} not found for statistics collection"
)
return
# Store the accumulated shortfall value a
t the time of match completion
#
This historical value will be used in reports instead of the current global value
# Store the accumulated shortfall value a
fter this match has already been applied
#
to the global persistent adjustment record.
accumulated_shortfall
=
self
.
_get_global_redistribution_adjustment
(
session
)
match
.
accumulated_shortfall
=
accumulated_shortfall
logger
.
info
(
f
"💰 [SHORTFALL TRACKING] Stored accumulated shortfall {accumulated_shortfall:.2f} in match {match_id} at completion time"
)
...
...
test_reports_sync_summary.py
0 → 100644
View file @
03d2e1c8
from
pathlib
import
Path
from
types
import
SimpleNamespace
from
mbetterclient.api_client.client
import
ReportsSyncResponseHandler
from
mbetterclient.core.message_bus
import
MessageBus
from
mbetterclient.database.manager
import
DatabaseManager
from
mbetterclient.database.models
import
BetDetailModel
,
BetModel
def
_build_handler
(
tmp_path
:
Path
):
db_path
=
tmp_path
/
"reports_sync_summary.db"
db_manager
=
DatabaseManager
(
str
(
db_path
))
assert
db_manager
.
initialize
()
handler
=
ReportsSyncResponseHandler
(
db_manager
=
db_manager
,
user_data_dir
=
str
(
tmp_path
),
api_client
=
SimpleNamespace
(
settings
=
SimpleNamespace
(
rustdesk_id
=
None
)),
message_bus
=
MessageBus
(),
)
return
handler
,
db_manager
def
test_calculate_summary_uses_settled_rows_and_unique_matches
(
tmp_path
):
handler
,
db_manager
=
_build_handler
(
tmp_path
)
session
=
db_manager
.
get_session
()
try
:
bet
=
BetModel
(
uuid
=
'bet-1'
,
fixture_id
=
'fixture-1'
,
paid
=
True
,
paid_out
=
False
)
session
.
add
(
bet
)
session
.
flush
()
session
.
add_all
([
BetDetailModel
(
bet_id
=
'bet-1'
,
match_id
=
10
,
match_number
=
1
,
outcome
=
'UNDER'
,
amount
=
10.0
,
win_amount
=
20.0
,
result
=
'win'
),
BetDetailModel
(
bet_id
=
'bet-1'
,
match_id
=
10
,
match_number
=
1
,
outcome
=
'OVER'
,
amount
=
15.0
,
win_amount
=
0.0
,
result
=
'lost'
),
BetDetailModel
(
bet_id
=
'bet-1'
,
match_id
=
11
,
match_number
=
2
,
outcome
=
'WIN1'
,
amount
=
25.0
,
win_amount
=
75.0
,
result
=
'win'
),
BetDetailModel
(
bet_id
=
'bet-1'
,
match_id
=
11
,
match_number
=
2
,
outcome
=
'KO1'
,
amount
=
5.0
,
win_amount
=
0.0
,
result
=
'cancelled'
),
])
session
.
commit
()
loaded_bet
=
session
.
query
(
BetModel
)
.
filter_by
(
uuid
=
'bet-1'
)
.
first
()
summary
=
handler
.
_calculate_summary
([
loaded_bet
],
stats
=
[])
assert
summary
[
'total_payin'
]
==
50.0
assert
summary
[
'total_payout'
]
==
95.0
assert
summary
[
'net_profit'
]
==
-
45.0
assert
summary
[
'total_bets'
]
==
3
assert
summary
[
'total_matches'
]
==
2
finally
:
session
.
close
()
db_manager
.
close
()
test_settlement_consistency.py
0 → 100644
View file @
03d2e1c8
from
pathlib
import
Path
from
mbetterclient.core.games_thread
import
GamesThread
from
mbetterclient.core.message_bus
import
MessageBus
from
mbetterclient.database.manager
import
DatabaseManager
from
mbetterclient.database.models
import
BetDetailModel
,
MatchModel
,
MatchOutcomeModel
def
_build_games_thread
(
tmp_path
:
Path
):
db_path
=
tmp_path
/
"settlement_consistency.db"
db_manager
=
DatabaseManager
(
str
(
db_path
))
assert
db_manager
.
initialize
()
message_bus
=
MessageBus
()
games_thread
=
GamesThread
(
"test_games_thread"
,
message_bus
,
db_manager
)
return
games_thread
,
db_manager
def
_seed_match
(
session
):
match
=
MatchModel
(
match_number
=
1
,
fighter1_township
=
'A'
,
fighter2_township
=
'B'
,
venue_kampala_township
=
'Kampala'
,
filename
=
'fixture.txt'
,
file_sha1sum
=
'fixture-sha1'
,
fixture_id
=
'fixture-1'
,
active_status
=
True
,
status
=
'done'
,
result
=
'WIN1'
,
under_over_result
=
'UNDER'
,
winning_outcomes
=
'["WIN1", "KO1"]'
,
)
session
.
add
(
match
)
session
.
flush
()
session
.
add_all
([
MatchOutcomeModel
(
match_id
=
match
.
id
,
column_name
=
'UNDER'
,
float_value
=
2.0
),
MatchOutcomeModel
(
match_id
=
match
.
id
,
column_name
=
'OVER'
,
float_value
=
3.0
),
MatchOutcomeModel
(
match_id
=
match
.
id
,
column_name
=
'WIN1'
,
float_value
=
4.0
),
MatchOutcomeModel
(
match_id
=
match
.
id
,
column_name
=
'KO1'
,
float_value
=
5.0
),
MatchOutcomeModel
(
match_id
=
match
.
id
,
column_name
=
'WIN2'
,
float_value
=
6.0
),
])
session
.
add_all
([
BetDetailModel
(
bet_id
=
'bet-under'
,
match_id
=
match
.
id
,
match_number
=
1
,
outcome
=
'UNDER'
,
amount
=
10.0
,
result
=
'pending'
),
BetDetailModel
(
bet_id
=
'bet-over'
,
match_id
=
match
.
id
,
match_number
=
1
,
outcome
=
'OVER'
,
amount
=
11.0
,
result
=
'pending'
),
BetDetailModel
(
bet_id
=
'bet-win1'
,
match_id
=
match
.
id
,
match_number
=
1
,
outcome
=
'WIN1'
,
amount
=
12.0
,
result
=
'pending'
),
BetDetailModel
(
bet_id
=
'bet-ko1'
,
match_id
=
match
.
id
,
match_number
=
1
,
outcome
=
'KO1'
,
amount
=
13.0
,
result
=
'pending'
),
BetDetailModel
(
bet_id
=
'bet-win2'
,
match_id
=
match
.
id
,
match_number
=
1
,
outcome
=
'WIN2'
,
amount
=
14.0
,
result
=
'pending'
),
])
session
.
commit
()
return
match
.
id
def
test_ensure_all_bets_resolved_settles_two_extraction_channels
(
tmp_path
):
games_thread
,
db_manager
=
_build_games_thread
(
tmp_path
)
session
=
db_manager
.
get_session
()
try
:
match_id
=
_seed_match
(
session
)
finally
:
session
.
close
()
games_thread
.
_ensure_all_bets_resolved
(
match_id
,
'WIN1'
)
session
=
db_manager
.
get_session
()
try
:
rows
=
{
row
.
outcome
:
row
for
row
in
session
.
query
(
BetDetailModel
)
.
filter_by
(
match_id
=
match_id
)
.
all
()
}
assert
rows
[
'UNDER'
]
.
result
==
'win'
assert
rows
[
'UNDER'
]
.
win_amount
==
20.0
assert
rows
[
'OVER'
]
.
result
==
'lost'
assert
rows
[
'OVER'
]
.
win_amount
==
0.0
assert
rows
[
'WIN1'
]
.
result
==
'win'
assert
rows
[
'WIN1'
]
.
win_amount
==
48.0
assert
rows
[
'KO1'
]
.
result
==
'win'
assert
rows
[
'KO1'
]
.
win_amount
==
65.0
assert
rows
[
'WIN2'
]
.
result
==
'lost'
assert
rows
[
'WIN2'
]
.
win_amount
==
0.0
assert
session
.
query
(
BetDetailModel
)
.
filter_by
(
match_id
=
match_id
,
result
=
'pending'
)
.
count
()
==
0
finally
:
session
.
close
()
db_manager
.
close
()
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment