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
2bcbdd45
Commit
2bcbdd45
authored
May 09, 2026
by
Your Name
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: align reports and repair historical stats
parent
e9e7b98b
Changes
4
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
726 additions
and
7 deletions
+726
-7
migrations.py
mbetterclient/database/migrations.py
+118
-1
routes.py
mbetterclient/web_dashboard/routes.py
+19
-6
test_reports_daily_alignment.py
test_reports_daily_alignment.py
+318
-0
test_reports_stats_repair_migration.py
test_reports_stats_repair_migration.py
+271
-0
No files found.
mbetterclient/database/migrations.py
View file @
2bcbdd45
...
...
@@ -7541,6 +7541,122 @@ class Migration_068_ResetRedistributionBalance(DatabaseMigration):
return
True
class
Migration_069_RepairExtractionStatsFinancials
(
DatabaseMigration
):
"""Repair historical extraction stats financial fields from authoritative bet details"""
def
__init__
(
self
):
super
()
.
__init__
(
"069"
,
"Repair extraction stats financial fields"
)
def
_snapshot_financial_fields
(
self
,
stat_row
):
return
{
'total_bets'
:
getattr
(
stat_row
,
'total_bets'
,
0
),
'total_amount_collected'
:
getattr
(
stat_row
,
'total_amount_collected'
,
0.0
),
'total_redistributed'
:
getattr
(
stat_row
,
'total_redistributed'
,
0.0
),
'under_bets'
:
getattr
(
stat_row
,
'under_bets'
,
0
),
'under_amount'
:
getattr
(
stat_row
,
'under_amount'
,
0.0
),
'over_bets'
:
getattr
(
stat_row
,
'over_bets'
,
0
),
'over_amount'
:
getattr
(
stat_row
,
'over_amount'
,
0.0
),
'result_breakdown'
:
getattr
(
stat_row
,
'result_breakdown'
,
None
),
}
def
_build_delta_log
(
self
,
match_id
,
before_values
,
after_values
):
changes
=
[]
for
field_name
,
before_value
in
before_values
.
items
():
after_value
=
after_values
[
field_name
]
if
before_value
!=
after_value
:
changes
.
append
(
f
"{field_name}: {before_value} -> {after_value}"
)
if
not
changes
:
return
None
return
f
"Repaired extraction stats row for match_id={match_id}: "
+
", "
.
join
(
changes
)
def
_rebuild_result_breakdown
(
self
,
stat_row
,
active_details
):
breakdown
=
{}
for
detail
in
active_details
:
outcome
=
getattr
(
detail
,
'outcome'
,
None
)
if
not
outcome
:
continue
if
outcome
not
in
breakdown
:
breakdown
[
outcome
]
=
{
'bets'
:
0
,
'amount'
:
0.0
}
breakdown
[
outcome
][
'bets'
]
+=
1
breakdown
[
outcome
][
'amount'
]
+=
float
(
getattr
(
detail
,
'amount'
,
0
)
or
0
)
stat_row
.
result_breakdown
=
breakdown
def
_recompute_stat_row
(
self
,
stat_row
,
detail_rows
):
active_details
=
[
detail
for
detail
in
detail_rows
if
getattr
(
detail
,
'result'
,
None
)
!=
'cancelled'
]
stat_row
.
total_bets
=
len
(
active_details
)
stat_row
.
total_amount_collected
=
sum
(
float
(
getattr
(
detail
,
'amount'
,
0
)
or
0
)
for
detail
in
active_details
)
stat_row
.
total_redistributed
=
sum
(
float
(
getattr
(
detail
,
'win_amount'
,
0
)
or
0
)
for
detail
in
active_details
if
getattr
(
detail
,
'result'
,
None
)
==
'win'
)
stat_row
.
under_bets
=
sum
(
1
for
detail
in
active_details
if
getattr
(
detail
,
'outcome'
,
None
)
==
'UNDER'
)
stat_row
.
under_amount
=
sum
(
float
(
getattr
(
detail
,
'amount'
,
0
)
or
0
)
for
detail
in
active_details
if
getattr
(
detail
,
'outcome'
,
None
)
==
'UNDER'
)
stat_row
.
over_bets
=
sum
(
1
for
detail
in
active_details
if
getattr
(
detail
,
'outcome'
,
None
)
==
'OVER'
)
stat_row
.
over_amount
=
sum
(
float
(
getattr
(
detail
,
'amount'
,
0
)
or
0
)
for
detail
in
active_details
if
getattr
(
detail
,
'outcome'
,
None
)
==
'OVER'
)
self
.
_rebuild_result_breakdown
(
stat_row
,
active_details
)
return
stat_row
def
up
(
self
,
db_manager
)
->
bool
:
session
=
None
try
:
from
.models
import
ExtractionStatsModel
,
BetDetailModel
session
=
db_manager
.
get_session
()
stats_rows
=
session
.
query
(
ExtractionStatsModel
)
.
all
()
modified_rows
=
0
match_counts
=
{}
for
stat_row
in
stats_rows
:
match_counts
[
stat_row
.
match_id
]
=
match_counts
.
get
(
stat_row
.
match_id
,
0
)
+
1
for
match_id
,
row_count
in
match_counts
.
items
():
if
row_count
>
1
:
logger
.
warning
(
f
"Duplicate extraction stats rows detected for match_id {match_id}: {row_count} rows"
)
for
stat_row
in
stats_rows
:
detail_rows
=
session
.
query
(
BetDetailModel
)
.
filter_by
(
match_id
=
stat_row
.
match_id
)
.
all
()
before_values
=
self
.
_snapshot_financial_fields
(
stat_row
)
self
.
_recompute_stat_row
(
stat_row
,
detail_rows
)
after_values
=
self
.
_snapshot_financial_fields
(
stat_row
)
delta_log
=
self
.
_build_delta_log
(
stat_row
.
match_id
,
before_values
,
after_values
)
if
delta_log
:
modified_rows
+=
1
logger
.
info
(
delta_log
)
session
.
commit
()
logger
.
info
(
f
"Modified {modified_rows} extraction stats rows out of {len(stats_rows)} processed"
)
return
True
except
Exception
as
e
:
logger
.
error
(
f
"Failed to repair extraction stats financial fields: {e}"
)
if
session
:
session
.
rollback
()
return
False
finally
:
if
session
:
session
.
close
()
def
down
(
self
,
db_manager
)
->
bool
:
"""Cannot rollback this migration because prior incorrect values are not preserved"""
logger
.
warning
(
"Cannot rollback Migration_069_RepairExtractionStatsFinancials - original financial values not preserved"
)
return
True
MIGRATIONS
:
List
[
DatabaseMigration
]
=
[
Migration_001_InitialSchema
(),
Migration_002_AddIndexes
(),
...
...
@@ -7610,6 +7726,7 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_066_AddWalletTables
(),
Migration_067_FixMatchResults
(),
Migration_068_ResetRedistributionBalance
(),
Migration_069_RepairExtractionStatsFinancials
(),
]
...
...
@@ -7821,4 +7938,4 @@ def get_migration_status(db_manager) -> Dict[str, Any]:
'total_migrations'
:
0
,
'applied_migrations'
:
0
,
'pending_migrations'
:
0
}
\ No newline at end of file
}
mbetterclient/web_dashboard/routes.py
View file @
2bcbdd45
...
...
@@ -7944,6 +7944,8 @@ def get_match_reports():
'payout'
:
0.0
}
match
=
session
.
query
(
MatchModel
)
.
filter_by
(
id
=
match_id
)
.
first
()
match_stats
[
match_id
][
'bets_count'
]
+=
1
match_stats
[
match_id
][
'payin'
]
+=
float
(
detail
.
amount
)
...
...
@@ -8176,11 +8178,22 @@ def download_excel_report():
total_payin
+=
bet_total
total_bets
+=
len
(
bet_details
)
extraction_stats
=
session
.
query
(
ExtractionStatsModel
)
.
filter
(
ExtractionStatsModel
.
match_datetime
>=
start_datetime
,
ExtractionStatsModel
.
match_datetime
<=
end_datetime
)
.
all
()
total_payout
=
sum
(
float
(
stat
.
total_redistributed
)
for
stat
in
extraction_stats
)
bet_details_query
=
session
.
query
(
BetDetailModel
)
.
join
(
BetModel
,
BetDetailModel
.
bet_id
==
BetModel
.
uuid
)
.
filter
(
BetModel
.
bet_datetime
>=
start_datetime
,
BetModel
.
bet_datetime
<=
end_datetime
,
BetDetailModel
.
result
!=
'cancelled'
)
bet_details
=
bet_details_query
.
all
()
total_payout
=
0.0
for
detail
in
bet_details
:
match
=
session
.
query
(
MatchModel
)
.
filter_by
(
id
=
detail
.
match_id
)
.
first
()
if
is_bet_detail_winning
(
detail
,
match
,
session
):
total_payout
+=
float
(
detail
.
win_amount
or
0
)
unique_match_ids
=
{
detail
.
match_id
for
detail
in
bet_details
}
# Summary headers
ws_summary
[
'A3'
]
=
"Metric"
...
...
@@ -8196,7 +8209,7 @@ def download_excel_report():
(
"Total Payout"
,
f
"{total_payout:.2f}"
),
(
"Net Profit"
,
f
"{total_payin - total_payout:.2f}"
),
(
"Total Bets"
,
str
(
total_bets
)),
(
"Total Matches"
,
str
(
len
(
extraction_stat
s
)))
(
"Total Matches"
,
str
(
len
(
unique_match_id
s
)))
]
for
i
,
(
metric
,
value
)
in
enumerate
(
summary_data
,
4
):
...
...
test_reports_daily_alignment.py
0 → 100644
View file @
2bcbdd45
This diff is collapsed.
Click to expand it.
test_reports_stats_repair_migration.py
0 → 100644
View file @
2bcbdd45
import
os
import
sys
from
datetime
import
datetime
from
types
import
SimpleNamespace
from
sqlalchemy
import
create_engine
from
sqlalchemy.orm
import
sessionmaker
sys
.
path
.
insert
(
0
,
os
.
path
.
dirname
(
os
.
path
.
abspath
(
__file__
)))
from
mbetterclient.database.migrations
import
Migration_069_RepairExtractionStatsFinancials
from
mbetterclient.database.models
import
Base
,
BetDetailModel
,
ExtractionStatsModel
,
MatchModel
class
StatRow
:
def
__init__
(
self
):
self
.
match_id
=
10
self
.
total_bets
=
99
self
.
total_amount_collected
=
9999.0
self
.
total_redistributed
=
8888.0
self
.
under_bets
=
9
self
.
under_amount
=
999.0
self
.
over_bets
=
8
self
.
over_amount
=
888.0
self
.
actual_result
=
'WIN1'
self
.
extraction_result
=
'WIN1'
self
.
match_datetime
=
'keep-me'
self
.
result_breakdown
=
{
'WIN1'
:
{
'bets'
:
2
,
'amount'
:
50.0
}}
class
DetailRow
:
def
__init__
(
self
,
outcome
,
amount
,
win_amount
,
result
):
self
.
outcome
=
outcome
self
.
amount
=
amount
self
.
win_amount
=
win_amount
self
.
result
=
result
class
SessionStub
:
def
__init__
(
self
,
stats_rows
,
detail_rows_by_match_id
):
self
.
stats_rows
=
list
(
stats_rows
)
self
.
detail_rows_by_match_id
=
dict
(
detail_rows_by_match_id
)
self
.
committed
=
False
self
.
rolled_back
=
False
self
.
closed
=
False
def
query
(
self
,
model
):
model_name
=
getattr
(
model
,
'__name__'
,
str
(
model
))
if
model_name
==
'ExtractionStatsModel'
:
return
QueryStub
(
self
.
stats_rows
)
if
model_name
==
'BetDetailModel'
:
return
DetailQueryStub
(
self
.
detail_rows_by_match_id
)
raise
AssertionError
(
f
'unexpected model: {model_name}'
)
def
commit
(
self
):
self
.
committed
=
True
def
rollback
(
self
):
self
.
rolled_back
=
True
def
close
(
self
):
self
.
closed
=
True
class
QueryStub
:
def
__init__
(
self
,
rows
):
self
.
rows
=
rows
def
all
(
self
):
return
list
(
self
.
rows
)
class
DetailQueryStub
:
def
__init__
(
self
,
detail_rows_by_match_id
):
self
.
detail_rows_by_match_id
=
detail_rows_by_match_id
def
filter_by
(
self
,
**
kwargs
):
match_id
=
kwargs
[
'match_id'
]
return
QueryStub
(
self
.
detail_rows_by_match_id
.
get
(
match_id
,
[]))
class
DummyDbManager
:
def
__init__
(
self
,
session
):
self
.
_session
=
session
def
get_session
(
self
):
return
self
.
_session
def
test_repair_migration_recomputes_financial_fields_from_bet_details
():
migration
=
Migration_069_RepairExtractionStatsFinancials
()
stat
=
StatRow
()
details
=
[
DetailRow
(
'UNDER'
,
100.0
,
0.0
,
'lost'
),
DetailRow
(
'WIN1'
,
50.0
,
125.0
,
'win'
),
DetailRow
(
'OVER'
,
75.0
,
0.0
,
'cancelled'
),
]
repaired
=
migration
.
_recompute_stat_row
(
stat
,
details
)
assert
repaired
.
total_bets
==
2
assert
repaired
.
total_amount_collected
==
150.0
assert
repaired
.
total_redistributed
==
125.0
assert
repaired
.
under_bets
==
1
assert
repaired
.
under_amount
==
100.0
assert
repaired
.
over_bets
==
0
assert
repaired
.
over_amount
==
0.0
assert
repaired
.
actual_result
==
'WIN1'
assert
repaired
.
extraction_result
==
'WIN1'
assert
repaired
.
match_datetime
==
'keep-me'
assert
repaired
.
result_breakdown
==
{
'UNDER'
:
{
'bets'
:
1
,
'amount'
:
100.0
},
'WIN1'
:
{
'bets'
:
1
,
'amount'
:
50.0
},
}
def
test_repair_migration_ignores_unsupported_non_enum_result_values
():
migration
=
Migration_069_RepairExtractionStatsFinancials
()
stat
=
StatRow
()
details
=
[
DetailRow
(
'WIN1'
,
50.0
,
125.0
,
'won'
),
]
repaired
=
migration
.
_recompute_stat_row
(
stat
,
details
)
assert
repaired
.
total_bets
==
1
assert
repaired
.
total_amount_collected
==
50.0
assert
repaired
.
total_redistributed
==
0.0
assert
repaired
.
result_breakdown
==
{
'WIN1'
:
{
'bets'
:
1
,
'amount'
:
50.0
},
}
def
test_repair_migration_excludes_cancelled_bets_from_all_totals
():
migration
=
Migration_069_RepairExtractionStatsFinancials
()
stat
=
StatRow
()
details
=
[
DetailRow
(
'OVER'
,
40.0
,
500.0
,
'cancelled'
),
]
repaired
=
migration
.
_recompute_stat_row
(
stat
,
details
)
assert
repaired
.
total_bets
==
0
assert
repaired
.
total_amount_collected
==
0.0
assert
repaired
.
total_redistributed
==
0.0
assert
repaired
.
over_bets
==
0
assert
repaired
.
over_amount
==
0.0
assert
repaired
.
result_breakdown
==
{}
def
test_repair_migration_updates_rows_logs_duplicates_and_change_deltas
(
monkeypatch
):
migration
=
Migration_069_RepairExtractionStatsFinancials
()
stat_one
=
StatRow
()
stat_one
.
match_id
=
10
stat_two
=
StatRow
()
stat_two
.
match_id
=
10
stat_three
=
StatRow
()
stat_three
.
match_id
=
11
session
=
SessionStub
(
[
stat_one
,
stat_two
,
stat_three
],
{
10
:
[
DetailRow
(
'UNDER'
,
25.0
,
0.0
,
'lost'
)],
11
:
[
DetailRow
(
'OVER'
,
40.0
,
120.0
,
'win'
)],
},
)
models_module
=
SimpleNamespace
(
ExtractionStatsModel
=
type
(
'ExtractionStatsModel'
,
(),
{}),
BetDetailModel
=
type
(
'BetDetailModel'
,
(),
{}),
)
monkeypatch
.
setitem
(
sys
.
modules
,
'mbetterclient.database.models'
,
models_module
)
info_logs
=
[]
warning_logs
=
[]
monkeypatch
.
setattr
(
'mbetterclient.database.migrations.logger.info'
,
lambda
message
:
info_logs
.
append
(
message
))
monkeypatch
.
setattr
(
'mbetterclient.database.migrations.logger.warning'
,
lambda
message
:
warning_logs
.
append
(
message
))
success
=
migration
.
up
(
DummyDbManager
(
session
))
assert
success
is
True
assert
stat_one
.
total_bets
==
1
assert
stat_one
.
total_amount_collected
==
25.0
assert
stat_one
.
total_redistributed
==
0.0
assert
stat_two
.
total_bets
==
1
assert
stat_two
.
total_amount_collected
==
25.0
assert
stat_two
.
total_redistributed
==
0.0
assert
stat_three
.
total_bets
==
1
assert
stat_three
.
total_amount_collected
==
40.0
assert
stat_three
.
total_redistributed
==
120.0
assert
stat_three
.
result_breakdown
==
{
'OVER'
:
{
'bets'
:
1
,
'amount'
:
40.0
}}
assert
session
.
committed
is
True
assert
session
.
rolled_back
is
False
assert
session
.
closed
is
True
assert
any
(
'Duplicate extraction stats rows detected for match_id 10'
in
message
for
message
in
warning_logs
)
assert
any
(
'Modified 3 extraction stats rows'
in
message
for
message
in
info_logs
)
assert
any
(
'match_id=10'
in
message
and
'total_bets: 99 -> 1'
in
message
for
message
in
info_logs
)
assert
any
(
'match_id=10'
in
message
and
'result_breakdown:'
in
message
for
message
in
info_logs
)
assert
any
(
'match_id=11'
in
message
and
'total_redistributed: 8888.0 -> 120.0'
in
message
for
message
in
info_logs
)
def
test_repair_migration_runs_against_real_sqlalchemy_models
(
monkeypatch
):
engine
=
create_engine
(
'sqlite:///:memory:'
)
Base
.
metadata
.
create_all
(
engine
)
Session
=
sessionmaker
(
bind
=
engine
)
session
=
Session
()
match
=
MatchModel
(
id
=
77
,
match_number
=
1
,
fighter1_township
=
'Town A'
,
fighter2_township
=
'Town B'
,
venue_kampala_township
=
'Kampala'
,
filename
=
'fixture.json'
,
file_sha1sum
=
'sha1-fixture'
,
fixture_id
=
'fixture-77'
,
start_time
=
datetime
(
2026
,
5
,
1
,
12
,
0
,
0
),
status
=
'done'
,
result
=
'WIN1'
,
winning_outcomes
=
[
'WIN1'
],
active_status
=
False
,
)
stat
=
ExtractionStatsModel
(
match_id
=
77
,
fixture_id
=
'fixture-77'
,
match_datetime
=
datetime
(
2026
,
5
,
1
,
12
,
0
,
0
),
total_bets
=
9
,
total_amount_collected
=
900.0
,
total_redistributed
=
800.0
,
actual_result
=
'WIN1'
,
result_breakdown
=
'{"LEGACY":{"bets":9,"amount":900.0,"coefficient":2.0}}'
,
under_bets
=
4
,
under_amount
=
400.0
,
over_bets
=
5
,
over_amount
=
500.0
,
extraction_result
=
'WIN1'
,
cap_applied
=
False
,
)
detail_win
=
BetDetailModel
(
match_id
=
77
,
bet_id
=
'bet-1'
,
outcome
=
'WIN1'
,
amount
=
50.0
,
win_amount
=
125.0
,
result
=
'win'
)
detail_under
=
BetDetailModel
(
match_id
=
77
,
bet_id
=
'bet-2'
,
outcome
=
'UNDER'
,
amount
=
25.0
,
win_amount
=
0.0
,
result
=
'lost'
)
detail_cancelled
=
BetDetailModel
(
match_id
=
77
,
bet_id
=
'bet-3'
,
outcome
=
'OVER'
,
amount
=
100.0
,
win_amount
=
999.0
,
result
=
'cancelled'
)
session
.
add_all
([
match
,
stat
,
detail_win
,
detail_under
,
detail_cancelled
])
session
.
commit
()
info_logs
=
[]
warning_logs
=
[]
monkeypatch
.
setattr
(
'mbetterclient.database.migrations.logger.info'
,
lambda
message
:
info_logs
.
append
(
message
))
monkeypatch
.
setattr
(
'mbetterclient.database.migrations.logger.warning'
,
lambda
message
:
warning_logs
.
append
(
message
))
migration
=
Migration_069_RepairExtractionStatsFinancials
()
success
=
migration
.
up
(
DummyDbManager
(
session
))
session
.
expire_all
()
repaired
=
session
.
query
(
ExtractionStatsModel
)
.
filter_by
(
match_id
=
77
)
.
one
()
assert
success
is
True
assert
repaired
.
total_bets
==
2
assert
repaired
.
total_amount_collected
==
75.0
assert
repaired
.
total_redistributed
==
125.0
assert
repaired
.
under_bets
==
1
assert
repaired
.
under_amount
==
25.0
assert
repaired
.
over_bets
==
0
assert
repaired
.
over_amount
==
0.0
assert
repaired
.
get_result_breakdown
()
==
{
'UNDER'
:
{
'bets'
:
1
,
'amount'
:
25.0
},
'WIN1'
:
{
'bets'
:
1
,
'amount'
:
50.0
},
}
assert
any
(
'Modified 1 extraction stats rows out of 1 processed'
in
message
for
message
in
info_logs
)
assert
any
(
'result_breakdown:'
in
message
for
message
in
info_logs
)
assert
not
warning_logs
session
.
close
()
engine
.
dispose
()
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