Commit 03d2e1c8 authored by Your Name's avatar Your Name

Fix settlement and report sync accounting

parent b57de374
......@@ -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(stats)
'total_matches': len(unique_match_ids)
}
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)
......@@ -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 at the time of match completion
# This historical value will be used in reports instead of the current global value
# Store the accumulated shortfall value after 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")
......
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()
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()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment