from otree.api import * c = cu doc = '' class C(BaseConstants): NAME_IN_URL = 'Market' PLAYERS_PER_GROUP = None NUM_ROUNDS = 15 INITIAL_CASH = 100 INVESTMENT_AMOUNT = 50 CASH_RETURN_RATE = 0.05 FUND_RETURN_RATE = 0.02 REDEMPTION_INCREMENT = 10 FIRST_RANDOM_END_PERIOD = 10 MAX_PERIODS = 15 CONTINUE_PROBABILITY = 0.75 DECISION_TIMEOUT_SECONDS = 120 ECU_PER_DOLLAR = 20 PREDICTION_BONUS = 1 DEBUG = True PREDICTION_PERIODS = (2, 5, 8, 10) ELIGIBILITY_PERIODS = (1, 1, 1, 1, 1, 2, 3, 4, 5, 6, 7, 8) MARKET_SIZE = 12 VALID_SESSION_SIZES = (12, 24, 36) BRET_NUM_BOXES = 100 BRET_DOLLARS_PER_BOX = 0.1 SHOW_UP_FEE = 7 class Subsession(BaseSubsession): pass def creating_session(subsession: Subsession): session = subsession.session import random if subsession.round_number == 1: players = subsession.get_players() num_players = len(players) if num_players not in C.VALID_SESSION_SIZES: raise Exception( f"Session must contain 12, 24, or 36 participants. " f"This session contains {num_players}." ) player_ids = [p.id_in_subsession for p in players] random.shuffle(player_ids) group_matrix = [] for i in range(0, num_players, C.MARKET_SIZE): group_matrix.append( player_ids[i:i + C.MARKET_SIZE] ) subsession.set_group_matrix(group_matrix) for group in subsession.get_groups(): # -------------------------------------------------- # HIDDEN SCHEDULED END PERIOD # -------------------------------------------------- scheduled_end_period = C.MAX_PERIODS for possible_end_period in range( C.FIRST_RANDOM_END_PERIOD, C.MAX_PERIODS ): continuation_draw = random.random() if continuation_draw >= C.CONTINUE_PROBABILITY: scheduled_end_period = possible_end_period break group.scheduled_end_period = scheduled_end_period # -------------------------------------------------- # ELIGIBILITY RANDOMIZATION # -------------------------------------------------- eligibility_periods = list( C.ELIGIBILITY_PERIODS ) random.shuffle( eligibility_periods ) # -------------------------------------------------- # HIDDEN TIE PRIORITIES # -------------------------------------------------- tie_priorities = list( range( 1, C.MARKET_SIZE + 1 ) ) random.shuffle( tie_priorities ) for p, eligibility, priority in zip( group.get_players(), eligibility_periods, tie_priorities ): for r in range( 1, C.NUM_ROUNDS + 1 ): round_player = p.in_round(r) round_player.eligibility_period = eligibility round_player.tie_priority = priority else: subsession.group_like_round(1) class Group(BaseGroup): market_open_start = models.BooleanField() fund_cash_start = models.FloatField() num_new_investments = models.IntegerField() new_investment_total = models.FloatField() fund_cash_after_entries = models.FloatField() num_redemption_requests = models.IntegerField() num_redemptions_paid = models.IntegerField() successful_redemption_total = models.FloatField() fund_cash_after_redemptions = models.FloatField() collapsed_this_period = models.BooleanField() shortfall_request_amount = models.FloatField() fund_interest = models.FloatField() fund_cash_after_interest = models.FloatField() ended_this_period = models.BooleanField() end_reason = models.StringField() continuation_draw = models.FloatField() continues_to_next_period = models.BooleanField() num_investors_settled = models.IntegerField() settlement_share = models.FloatField() settlement_total = models.FloatField() fund_cash_end = models.FloatField() market_open_end = models.BooleanField() scheduled_end_period = models.IntegerField() passive_period = models.BooleanField() final_period = models.BooleanField() final_reason = models.StringField() def initialize_round(group: Group): session = group.session round_number = group.round_number players = group.get_players() # -------------------------------------------------- # CARRY MARKET STATE FORWARD # -------------------------------------------------- if round_number == 1: group.market_open_start = True group.fund_cash_start = 0 # scheduled_end_period was already assigned # during creating_session. else: previous_group = ( group.in_round( round_number - 1 ) ) group.market_open_start = ( previous_group.market_open_end ) group.fund_cash_start = ( previous_group.fund_cash_end ) group.scheduled_end_period = ( previous_group.scheduled_end_period ) # -------------------------------------------------- # PERIOD TYPE # -------------------------------------------------- group.passive_period = ( not group.market_open_start ) group.final_period = False group.final_reason = "" # -------------------------------------------------- # RESET GROUP PERIOD FIELDS # -------------------------------------------------- group.num_new_investments = 0 group.new_investment_total = 0 group.fund_cash_after_entries = ( group.fund_cash_start ) group.num_redemption_requests = 0 group.num_redemptions_paid = 0 group.successful_redemption_total = 0 group.fund_cash_after_redemptions = ( group.fund_cash_start ) group.collapsed_this_period = False group.shortfall_request_amount = 0 group.fund_interest = 0 group.fund_cash_after_interest = ( group.fund_cash_start ) group.ended_this_period = False group.end_reason = "" group.continuation_draw = 0 group.continues_to_next_period = False group.num_investors_settled = 0 group.settlement_share = 0 group.settlement_total = 0 group.fund_cash_end = ( group.fund_cash_start ) group.market_open_end = ( group.market_open_start ) # -------------------------------------------------- # PLAYER STATE # -------------------------------------------------- for p in players: if round_number == 1: p.cash_start = ( C.INITIAL_CASH ) p.ever_invested_start = False p.currently_invested_start = False p.entry_period = 0 p.holding_periods_start = 0 p.redemption_amount_start = 0 else: previous = ( p.in_round( round_number - 1 ) ) p.cash_start = ( previous.cash_end ) p.ever_invested_start = ( previous.ever_invested_end ) p.currently_invested_start = ( previous.currently_invested_end ) p.entry_period = ( previous.entry_period ) p.holding_periods_start = ( previous.holding_periods_end ) p.redemption_amount_start = ( previous.redemption_amount_end ) # -------------------------------------------------- # DECISION ELIGIBILITY # # Even if technically eligible by date, # nobody can make fund decisions once # the fund has closed. # -------------------------------------------------- p.eligible_this_period = ( round_number >= p.eligibility_period ) p.can_invest = ( group.market_open_start and p.eligible_this_period and not p.ever_invested_start ) p.can_redeem = ( group.market_open_start and p.currently_invested_start and p.entry_period < round_number ) # -------------------------------------------------- # CASH ACCOUNT # -------------------------------------------------- p.cash_interest_base = ( p.cash_start ) p.cash_interest = 0 p.cash_end = ( p.cash_start ) # -------------------------------------------------- # ENTRY # -------------------------------------------------- p.entry_choice = "" p.invested_this_period = False p.entry_timed_out = False p.ever_invested_end = ( p.ever_invested_start ) p.currently_invested_end = ( p.currently_invested_start ) p.holding_periods_end = ( p.holding_periods_start ) p.redemption_amount_end = ( p.redemption_amount_start ) # -------------------------------------------------- # REDEMPTION # -------------------------------------------------- p.redemption_choice = "" p.requested_redemption = False p.redemption_processing_order = 0 p.redemption_paid = False p.redemption_payment = 0 p.redemption_timed_out = False p.shortfall_trigger = False p.settlement_payment = 0 # -------------------------------------------------- # PREDICTION FLAGS # -------------------------------------------------- p.prediction_timed_out = False p.prediction_own_question_shown = False p.prediction_target_period_occurred = False p.prediction_new_investments_bonus_eligible = False p.prediction_redemption_requests_bonus_eligible = False p.prediction_fund_closes_bonus_eligible = False p.prediction_own_redemption_paid_bonus_eligible = False p.prediction_new_investments_correct = False p.prediction_redemption_requests_correct = False p.prediction_fund_closes_correct = False p.prediction_own_redemption_paid_correct = False def process_entries(group: Group): players = group.get_players() num_new_investments = 0 # If the market was already closed before this period, # no entry processing occurs. if not group.market_open_start: group.num_new_investments = 0 group.new_investment_total = 0 group.fund_cash_after_entries = group.fund_cash_start return for p in players: if p.can_invest and p.entry_choice == "invest": p.invested_this_period = True p.ever_invested_end = True p.currently_invested_end = True p.entry_period = group.round_number # 50 ECU leaves the participant's Cash Account. # Only the remaining Cash Account balance will # earn the 5% return this period. p.cash_interest_base = ( p.cash_start - C.INVESTMENT_AMOUNT ) num_new_investments += 1 else: p.invested_this_period = False # If no investment occurs, the full starting # Cash Account balance remains eligible for interest. p.cash_interest_base = p.cash_start group.num_new_investments = num_new_investments group.new_investment_total = ( num_new_investments * C.INVESTMENT_AMOUNT ) group.fund_cash_after_entries = ( group.fund_cash_start + group.new_investment_total ) def process_redemptions(group: Group): players = group.get_players() # Start redemption processing with fund cash # after this period's new investments were added. available_cash = group.fund_cash_after_entries # -------------------------------------------------- # IDENTIFY REDEMPTION REQUESTS # -------------------------------------------------- requesters = [] for p in players: if ( p.can_redeem and p.redemption_choice == "redeem" ): p.requested_redemption = True requesters.append(p) else: p.requested_redemption = False group.num_redemption_requests = len(requesters) # -------------------------------------------------- # PROCESSING ORDER # # Earlier entry period first. # Same-entry-period ties use hidden fixed priority. # Lower priority number is processed first. # -------------------------------------------------- requesters = sorted( requesters, key=lambda p: ( p.entry_period, p.tie_priority ) ) for order, p in enumerate(requesters, start=1): p.redemption_processing_order = order # -------------------------------------------------- # PROCESS REQUESTS # -------------------------------------------------- for p in requesters: requested_amount = p.redemption_amount_start if available_cash >= requested_amount: # Full payment succeeds available_cash -= requested_amount p.redemption_paid = True p.redemption_payment = requested_amount p.currently_invested_end = False group.num_redemptions_paid += 1 group.successful_redemption_total += requested_amount else: # The first request that cannot be paid # causes immediate fund closure. p.shortfall_trigger = True group.collapsed_this_period = True group.shortfall_request_amount = requested_amount break # Cash remaining after successful voluntary redemptions group.fund_cash_after_redemptions = available_cash # -------------------------------------------------- # COLLAPSE SETTLEMENT # -------------------------------------------------- if group.collapsed_this_period: remaining_investors = [ p for p in players if p.currently_invested_end ] group.num_investors_settled = len( remaining_investors ) if group.num_investors_settled > 0: group.settlement_share = ( available_cash / group.num_investors_settled ) else: group.settlement_share = 0 group.settlement_total = available_cash for p in remaining_investors: p.settlement_payment = ( group.settlement_share ) # Their investment is liquidated because # the fund has closed. p.currently_invested_end = False group.ended_this_period = True group.end_reason = "insufficient_cash" group.market_open_end = False # All remaining fund cash has been distributed. group.fund_cash_end = 0 def process_period_end(group: Group): players = group.get_players() # -------------------------------------------------- # CASH ACCOUNT RETURN # # Cash held in the Cash Account for the full period # earns 5%. # # This happens in BOTH: # - normal active-market periods # - passive cash-only periods after an early collapse # -------------------------------------------------- for p in players: p.cash_interest = ( p.cash_interest_base * C.CASH_RETURN_RATE ) p.cash_end = ( p.cash_interest_base + p.cash_interest + p.redemption_payment + p.settlement_payment ) # ================================================== # CASE 1: # PASSIVE CASH-ONLY PERIOD # # The fund closed in an earlier period. # There are no investments, redemptions, # holding-period changes, or fund returns. # # Cash Accounts still earn 5%. # ================================================== if group.passive_period: group.fund_interest = 0 group.fund_cash_after_interest = 0 group.fund_cash_end = 0 group.market_open_end = False return # ================================================== # CASE 2: # FUND COLLAPSED THIS PERIOD # # process_redemptions() has already: # - paid successful redemption requests # - distributed remaining fund cash equally # - closed all remaining investments # - reduced fund cash to zero # # Redemption payments and settlement payments # enter the Cash Account at the end of this period, # so they do NOT earn the 5% return until next period. # ================================================== if group.collapsed_this_period: group.fund_interest = 0 group.fund_cash_after_interest = 0 group.fund_cash_end = 0 group.market_open_end = False return # ================================================== # CASE 3: # NORMAL ACTIVE FUND # # Update holding periods and redemption amounts. # ================================================== for p in players: if p.currently_invested_end: p.holding_periods_end = ( p.holding_periods_start + 1 ) p.redemption_amount_end = ( C.INVESTMENT_AMOUNT + ( C.REDEMPTION_INCREMENT * p.holding_periods_end ) ) else: p.holding_periods_end = ( p.holding_periods_start ) p.redemption_amount_end = ( p.redemption_amount_start ) # -------------------------------------------------- # FUND RETURN # # Remaining fund cash earns 2% after redemption # processing. # -------------------------------------------------- group.fund_interest = ( group.fund_cash_after_redemptions * C.FUND_RETURN_RATE ) group.fund_cash_after_interest = ( group.fund_cash_after_redemptions + group.fund_interest ) group.fund_cash_end = ( group.fund_cash_after_interest ) group.market_open_end = True def score_previous_predictions(group: Group): session = group.session subsession = group.subsession round_number = group.round_number # Predictions made in Periods 2, 5, 8, and 10 # concern Periods 3, 6, 9, and 11 respectively. prediction_round = round_number - 1 if prediction_round not in C.PREDICTION_PERIODS: return # If this period did not actually occur, there is # nothing to score. if not group.market_open_start: return players = group.get_players() actual_new_investments = ( group.num_new_investments ) actual_redemption_requests = ( group.num_redemption_requests ) actual_fund_closes = ( 'yes' if group.collapsed_this_period else 'no' ) for current_player in players: prediction_player = ( current_player.in_round( prediction_round ) ) # -------------------------------------------------- # SAFELY READ PREDICTION RESPONSES # # field_maybe_none() is required because blank # prediction responses are intentional when a # participant times out or Q4 was not applicable. # -------------------------------------------------- q1_prediction = ( prediction_player.field_maybe_none( 'prediction_new_investments' ) ) q2_prediction = ( prediction_player.field_maybe_none( 'prediction_redemption_requests' ) ) q3_prediction = ( prediction_player.field_maybe_none( 'prediction_fund_closes' ) ) q4_prediction = ( prediction_player.field_maybe_none( 'prediction_own_redemption_paid' ) ) # -------------------------------------------------- # TARGET PERIOD OCCURRED # -------------------------------------------------- prediction_player.prediction_target_period_occurred = True # -------------------------------------------------- # STORE REALIZED OUTCOMES # -------------------------------------------------- prediction_player.prediction_new_investments_actual = ( actual_new_investments ) prediction_player.prediction_redemption_requests_actual = ( actual_redemption_requests ) prediction_player.prediction_fund_closes_actual = ( actual_fund_closes ) # -------------------------------------------------- # QUESTION 1: # NUMBER OF NEW INVESTMENTS # -------------------------------------------------- q1_answered = ( q1_prediction is not None ) prediction_player.prediction_new_investments_bonus_eligible = ( q1_answered ) if q1_answered: prediction_player.prediction_new_investments_correct = ( q1_prediction == actual_new_investments ) # -------------------------------------------------- # QUESTION 2: # NUMBER OF REDEMPTION REQUESTS # -------------------------------------------------- q2_answered = ( q2_prediction is not None ) prediction_player.prediction_redemption_requests_bonus_eligible = ( q2_answered ) if q2_answered: prediction_player.prediction_redemption_requests_correct = ( q2_prediction == actual_redemption_requests ) # -------------------------------------------------- # QUESTION 3: # FUND CLOSES FOR INSUFFICIENT CASH # -------------------------------------------------- q3_answered = ( q3_prediction in ['yes', 'no'] ) prediction_player.prediction_fund_closes_bonus_eligible = ( q3_answered ) if q3_answered: prediction_player.prediction_fund_closes_correct = ( q3_prediction == actual_fund_closes ) # -------------------------------------------------- # QUESTION 4: # WOULD THIS PARTICIPANT'S REDEMPTION BE PAID? # # This is scored even if the participant actually # chose Stay in the target period. # -------------------------------------------------- q4_applicable = ( prediction_player.prediction_own_question_shown ) q4_answered = ( q4_prediction in ['yes', 'no'] ) if q4_applicable: # -------------------------------------------------- # BUILD HYPOTHETICAL REDEMPTION QUEUE # -------------------------------------------------- # Begin with the actual redemption requesters # from this target period. hypothetical_requesters = [ p for p in players if p.requested_redemption ] # If this participant did not actually request # redemption, insert them into the hypothetical # queue. already_requested = any( p.id_in_subsession == current_player.id_in_subsession for p in hypothetical_requesters ) if not already_requested: hypothetical_requesters.append( current_player ) # -------------------------------------------------- # APPLY REDEMPTION PRIORITY # # Earlier entry period first. # Same-entry-period ties use hidden priority. # -------------------------------------------------- hypothetical_requesters = sorted( hypothetical_requesters, key=lambda p: ( p.entry_period, p.tie_priority ) ) # -------------------------------------------------- # SIMULATE THE HYPOTHETICAL QUEUE # -------------------------------------------------- hypothetical_cash = ( group.fund_cash_after_entries ) hypothetical_paid = False for requester in hypothetical_requesters: request_amount = ( requester.redemption_amount_start ) if hypothetical_cash >= request_amount: hypothetical_cash -= ( request_amount ) # If we have reached this participant # and their full request was affordable, # their counterfactual outcome is Yes. if ( requester.id_in_subsession == current_player.id_in_subsession ): hypothetical_paid = True break else: # Once any request cannot be paid in full, # processing stops and the fund closes. hypothetical_paid = False break hypothetical_result = ( 'yes' if hypothetical_paid else 'no' ) # -------------------------------------------------- # STORE Q4 REALIZED COUNTERFACTUAL # -------------------------------------------------- prediction_player.prediction_own_redemption_paid_actual = ( hypothetical_result ) # -------------------------------------------------- # Q4 BONUS ELIGIBILITY # # The participant does NOT need to have actually # requested redemption in the target period. # They simply need to have been shown Q4 and # actually provided a prediction. # -------------------------------------------------- prediction_player.prediction_own_redemption_paid_bonus_eligible = ( q4_answered ) if q4_answered: prediction_player.prediction_own_redemption_paid_correct = ( q4_prediction == hypothetical_result ) def process_redemptions_and_score(group: Group): process_redemptions(group) score_previous_predictions(group) def process_continuation(group: Group): players = group.get_players() round_number = group.round_number scheduled_end_period = ( group.scheduled_end_period ) # -------------------------------------------------- # DEFAULT PERIOD-END CONTINUATION STATE # -------------------------------------------------- group.continues_to_next_period = False group.final_period = False group.final_reason = "" # ================================================== # CASE 1: # FUND COLLAPSED FROM INSUFFICIENT CASH # THIS PERIOD # ================================================== if group.collapsed_this_period: # The fund itself is permanently closed. group.market_open_end = False group.fund_cash_end = 0 # -------------------------------------------------- # COLLAPSE BEFORE PERIOD 10 # # The active fund ends immediately, but the # experiment continues with passive Cash-Account # periods until the hidden scheduled ending period. # -------------------------------------------------- if round_number < C.FIRST_RANDOM_END_PERIOD: group.continues_to_next_period = True group.final_period = False group.final_reason = "" return # -------------------------------------------------- # COLLAPSE IN PERIOD 10 OR LATER # # The collapse period itself becomes the final # experiment period. # -------------------------------------------------- group.continues_to_next_period = False group.final_period = True group.final_reason = ( "insufficient_cash" ) return # ================================================== # CASE 2: # PASSIVE CASH-ONLY PERIOD # # The fund closed from insufficient cash in an # earlier period. There are no fund decisions, # but Cash Accounts continue earning 5%. # ================================================== if group.passive_period: group.market_open_end = False group.fund_cash_end = 0 # -------------------------------------------------- # THIS IS THE HIDDEN SCHEDULED END PERIOD # -------------------------------------------------- if round_number >= scheduled_end_period: group.continues_to_next_period = False group.final_period = True if round_number == C.MAX_PERIODS: group.final_reason = ( "period_15_after_collapse" ) else: group.final_reason = ( "random_end_after_collapse" ) return # -------------------------------------------------- # MORE PASSIVE PERIODS REMAIN # -------------------------------------------------- group.continues_to_next_period = True group.final_period = False return # ================================================== # CASE 3: # ACTIVE FUND HAS NOT YET REACHED THE HIDDEN # SCHEDULED END PERIOD # ================================================== if round_number < scheduled_end_period: group.continues_to_next_period = True group.market_open_end = True group.final_period = False return # ================================================== # CASE 4: # ACTIVE FUND REACHES ITS HIDDEN SCHEDULED END # # This means either: # - random ending after Period 10-14, or # - mandatory ending after Period 15. # # The fund is liquidated equally among everyone # who is still invested. # ================================================== group.continues_to_next_period = False group.final_period = True group.ended_this_period = True group.market_open_end = False if round_number == C.MAX_PERIODS: group.end_reason = ( "period_15" ) group.final_reason = ( "period_15" ) else: group.end_reason = ( "random_end" ) group.final_reason = ( "random_end" ) # -------------------------------------------------- # IDENTIFY EVERYONE STILL INVESTED # -------------------------------------------------- remaining_investors = [ p for p in players if p.currently_invested_end ] group.num_investors_settled = ( len(remaining_investors) ) # -------------------------------------------------- # FUND CASH AVAILABLE FOR TERMINAL LIQUIDATION # # The 2% fund return has already been credited # by process_period_end(). # -------------------------------------------------- terminal_cash = ( group.fund_cash_after_interest ) # -------------------------------------------------- # EQUAL TERMINAL SETTLEMENT # # No seniority is used here. # -------------------------------------------------- if group.num_investors_settled > 0: group.settlement_share = ( terminal_cash / group.num_investors_settled ) group.settlement_total = ( terminal_cash ) for p in remaining_investors: p.settlement_payment += ( group.settlement_share ) # This settlement occurs after the current # period's 5% Cash-Account return, so it # does not earn 5% in this period. p.cash_end += ( group.settlement_share ) p.currently_invested_end = False else: group.settlement_share = 0 group.settlement_total = 0 # -------------------------------------------------- # THE FUND IS NOW COMPLETELY CLOSED # -------------------------------------------------- group.fund_cash_end = 0 def process_continuation_and_store(group: Group): # First run all continuation / terminal-settlement logic. process_continuation(group) players = group.get_players() # -------------------------------------------------- # RECORD WHEN THE FUND ITSELF CLOSED # -------------------------------------------------- if group.ended_this_period: for p in players: p.participant.vars[ "fund_end_round" ] = group.round_number p.participant.vars[ "fund_end_reason" ] = group.end_reason # -------------------------------------------------- # FINAL EXPERIMENT OUTCOME # -------------------------------------------------- if group.final_period: import random for p in players: # ---------------------------------------------- # STORE FINAL MARKET OUTCOME # ---------------------------------------------- p.participant.vars[ "experiment_final_round" ] = group.round_number p.participant.vars[ "experiment_final_reason" ] = group.final_reason p.participant.vars[ "market_final_cash_ecu" ] = p.cash_end # ---------------------------------------------- # CONVERT FINAL MARKET ECU TO DOLLARS # # 20 ECU = $1 # ---------------------------------------------- p.market_payment_dollars = ( p.cash_end / C.ECU_PER_DOLLAR ) p.participant.vars[ "market_payment_dollars" ] = p.market_payment_dollars # ---------------------------------------------- # BUILD LIST OF ALL BONUS-ELIGIBLE # INDIVIDUAL PREDICTION RESPONSES # ---------------------------------------------- eligible_responses = [] for prediction_round in C.PREDICTION_PERIODS: prediction_player = ( p.in_round(prediction_round) ) question_data = [ ( "Q1", prediction_player.prediction_new_investments_bonus_eligible, prediction_player.prediction_new_investments_correct, ), ( "Q2", prediction_player.prediction_redemption_requests_bonus_eligible, prediction_player.prediction_redemption_requests_correct, ), ( "Q3", prediction_player.prediction_fund_closes_bonus_eligible, prediction_player.prediction_fund_closes_correct, ), ( "Q4", prediction_player.prediction_own_redemption_paid_bonus_eligible, prediction_player.prediction_own_redemption_paid_correct, ), ] for ( question, bonus_eligible, correct ) in question_data: if bonus_eligible: eligible_responses.append( ( prediction_round, question, correct, ) ) # ---------------------------------------------- # STORE NUMBER OF ELIGIBLE RESPONSES # ---------------------------------------------- p.prediction_bonus_num_eligible = ( len(eligible_responses) ) # ---------------------------------------------- # RANDOMLY SELECT ONE ELIGIBLE RESPONSE # ---------------------------------------------- if eligible_responses: ( selected_round, selected_question, selected_correct ) = random.choice( eligible_responses ) p.prediction_bonus_selected_round = ( selected_round ) p.prediction_bonus_selected_question = ( selected_question ) p.prediction_bonus_selected_correct = ( selected_correct ) if selected_correct: p.prediction_bonus_amount = ( C.PREDICTION_BONUS ) else: p.prediction_bonus_amount = 0 # ---------------------------------------------- # NO ELIGIBLE PREDICTIONS # # This could happen, for example, if the fund # closes so early that no prediction target # period ever occurs. # ---------------------------------------------- else: p.prediction_bonus_selected_round = 0 p.prediction_bonus_selected_question = "" p.prediction_bonus_selected_correct = False p.prediction_bonus_amount = 0 # ---------------------------------------------- # ALSO STORE IN PARTICIPANT.VARS # FOR EASY ACCESS IN LATER APPS / PAYMENT # ---------------------------------------------- p.participant.vars[ "prediction_bonus_num_eligible" ] = p.prediction_bonus_num_eligible p.participant.vars[ "prediction_bonus_selected_round" ] = p.prediction_bonus_selected_round p.participant.vars[ "prediction_bonus_selected_question" ] = p.prediction_bonus_selected_question p.participant.vars[ "prediction_bonus_selected_correct" ] = p.prediction_bonus_selected_correct p.participant.vars[ "prediction_bonus_amount" ] = p.prediction_bonus_amount class Player(BasePlayer): eligibility_period = models.IntegerField() tie_priority = models.IntegerField() cash_start = models.FloatField() cash_interest_base = models.FloatField() cash_interest = models.FloatField() cash_end = models.FloatField() eligible_this_period = models.BooleanField() can_invest = models.BooleanField() ever_invested_start = models.BooleanField() invested_this_period = models.BooleanField() ever_invested_end = models.BooleanField() entry_period = models.IntegerField() currently_invested_start = models.BooleanField() currently_invested_end = models.BooleanField() holding_periods_start = models.IntegerField() holding_periods_end = models.IntegerField() redemption_amount_start = models.FloatField() redemption_amount_end = models.FloatField() can_redeem = models.BooleanField() requested_redemption = models.BooleanField() redemption_processing_order = models.IntegerField() redemption_paid = models.BooleanField() redemption_payment = models.FloatField() shortfall_trigger = models.BooleanField() settlement_payment = models.FloatField() entry_timed_out = models.BooleanField() redemption_timed_out = models.BooleanField() entry_choice = models.StringField(widget=widgets.RadioSelect, choices=[['invest', 'Invest 50 ECU'], ['keep', 'Keep in Cash Account']]) redemption_choice = models.StringField(widget=widgets.RadioSelect, choices=[['redeem', 'Redeem'], ['stay', 'Stay']]) prediction_new_investments = models.IntegerField(choices=[[0, '0'], [1, '1'], [2, '2'], [3, '3'], [4, '4'], [5, '5'], [6, '6'], [7, '7'], [8, '8'], [9, '9'], [10, '10'], [11, '11'], [12, '12']]) prediction_redemption_requests = models.IntegerField(choices=[[0, '0'], [1, '1'], [2, '2'], [3, '3'], [4, '4'], [5, '5'], [6, '6'], [7, '7'], [8, '8'], [9, '9'], [10, '10'], [11, '11'], [12, '12']]) prediction_fund_closes = models.StringField(widget=widgets.RadioSelect, choices=[['yes', 'Yes'], ['no', 'No']]) prediction_own_redemption_paid = models.StringField(blank=True, widget=widgets.RadioSelect, choices=[['yes', 'Yes'], ['no', 'No']]) prediction_timed_out = models.BooleanField() prediction_own_question_shown = models.BooleanField() prediction_target_period_occurred = models.BooleanField() prediction_new_investments_actual = models.IntegerField() prediction_redemption_requests_actual = models.IntegerField() prediction_fund_closes_actual = models.StringField() prediction_own_redemption_paid_actual = models.StringField() prediction_new_investments_bonus_eligible = models.BooleanField() prediction_redemption_requests_bonus_eligible = models.BooleanField() prediction_fund_closes_bonus_eligible = models.BooleanField() prediction_own_redemption_paid_bonus_eligible = models.BooleanField() prediction_new_investments_correct = models.BooleanField() prediction_redemption_requests_correct = models.BooleanField() prediction_fund_closes_correct = models.BooleanField() prediction_own_redemption_paid_correct = models.BooleanField() prediction_bonus_num_eligible = models.IntegerField() prediction_bonus_selected_round = models.IntegerField() prediction_bonus_selected_question = models.StringField() prediction_bonus_selected_correct = models.BooleanField() prediction_bonus_amount = models.FloatField() market_payment_dollars = models.FloatField() bret_choice = models.IntegerField(min=0, max=100) bret_monster_box = models.IntegerField() bret_monster_collected = models.BooleanField() bret_payment_dollars = models.FloatField() total_payment_dollars = models.FloatField() def process_bret(player: Player): participant = player.participant import random # -------------------------------------------------- # RANDOMLY DETERMINE MONSTER LOCATION # # Every box from 1 through 100 has equal probability. # -------------------------------------------------- player.bret_monster_box = ( random.randint( 1, C.BRET_NUM_BOXES ) ) # -------------------------------------------------- # DETERMINE WHETHER THE MONSTER WAS COLLECTED # # Boxes are collected in numerical order. # # Example: # Choice = 40 # → boxes 1 through 40 are collected. # # Monster box <= 40 # → monster collected. # -------------------------------------------------- player.bret_monster_collected = ( player.bret_choice >= player.bret_monster_box ) # -------------------------------------------------- # DETERMINE BRET PAYMENT # # Monster collected: # $0 # # Monster not collected: # $0.10 for every collected box # -------------------------------------------------- if player.bret_monster_collected: player.bret_payment_dollars = 0 else: player.bret_payment_dollars = ( player.bret_choice * C.BRET_DOLLARS_PER_BOX ) # -------------------------------------------------- # STORE FOR FINAL PAYMENT CALCULATION # -------------------------------------------------- player.participant.vars[ "bret_choice" ] = player.bret_choice player.participant.vars[ "bret_monster_box" ] = player.bret_monster_box player.participant.vars[ "bret_monster_collected" ] = player.bret_monster_collected player.participant.vars[ "bret_payment_dollars" ] = player.bret_payment_dollars def calculate_final_payment(player: Player): participant = player.participant # -------------------------------------------------- # RETRIEVE PAYMENT COMPONENTS # -------------------------------------------------- market_payment = ( player.participant.vars.get( "market_payment_dollars", 0 ) ) prediction_bonus = ( player.participant.vars.get( "prediction_bonus_amount", 0 ) ) bret_payment = ( player.field_maybe_none( "bret_payment_dollars" ) ) if bret_payment is None: bret_payment = 0 # -------------------------------------------------- # CALCULATE TOTAL PAYMENT # -------------------------------------------------- player.total_payment_dollars = ( C.SHOW_UP_FEE + market_payment + prediction_bonus + bret_payment ) # -------------------------------------------------- # STORE ALL FINAL PAYMENT COMPONENTS # -------------------------------------------------- player.participant.vars[ "show_up_fee_dollars" ] = C.SHOW_UP_FEE player.participant.vars[ "market_payment_dollars" ] = market_payment player.participant.vars[ "prediction_bonus_amount" ] = prediction_bonus player.participant.vars[ "bret_payment_dollars" ] = bret_payment player.participant.vars[ "total_payment_dollars" ] = player.total_payment_dollars class PeriodStartWaitPage(WaitPage): after_all_players_arrive = initialize_round @staticmethod def is_displayed(player: Player): group = player.group if player.round_number == 1: return True previous_group = player.group.in_round( player.round_number - 1 ) return previous_group.continues_to_next_period class SetupDebug(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): return C.DEBUG and player.round_number == 1 @staticmethod def vars_for_template(player: Player): session = player.session subsession = player.subsession group = player.group participant = player.participant subsession = player.subsession all_players = subsession.get_players() all_groups = subsession.get_groups() rows = [] market_checks = [] # Build checks separately for each 12-person market for group in all_groups: group_players = group.get_players() eligibilities = [ p.eligibility_period for p in group_players ] priorities = [ p.tie_priority for p in group_players ] market_size_ok = ( len(group_players) == C.MARKET_SIZE ) eligibility_ok = ( sorted(eligibilities) == sorted(list(C.ELIGIBILITY_PERIODS)) ) priority_ok = ( sorted(priorities) == list(range(1, C.MARKET_SIZE + 1)) ) market_checks.append( dict( market_number=group.id_in_subsession, market_size=len(group_players), market_size_ok=market_size_ok, eligibility_ok=eligibility_ok, priority_ok=priority_ok, ) ) # Add each participant in this market to the master table for p in group_players: rows.append( dict( link_number=p.id_in_subsession, market_number=group.id_in_subsession, market_position=p.id_in_group, eligibility_period=p.eligibility_period, tie_priority=p.tie_priority, cash_start=p.cash_start, eligible_now=p.eligible_this_period, can_invest=p.can_invest, ever_invested=p.ever_invested_start, currently_invested=p.currently_invested_start, ) ) # Sort by P-number so table matches the links on the oTree session page rows = sorted( rows, key=lambda x: x['link_number'] ) market_checks = sorted( market_checks, key=lambda x: x['market_number'] ) session_size = len(all_players) num_markets = len(all_groups) session_size_ok = ( session_size in C.VALID_SESSION_SIZES ) expected_num_markets = ( session_size // C.MARKET_SIZE ) num_markets_ok = ( num_markets == expected_num_markets ) all_markets_ok = all( m['market_size_ok'] and m['eligibility_ok'] and m['priority_ok'] for m in market_checks ) return dict( rows=rows, market_checks=market_checks, session_size=session_size, session_size_ok=session_size_ok, num_markets=num_markets, num_markets_ok=num_markets_ok, all_markets_ok=all_markets_ok, current_link=player.id_in_subsession, round_number=player.round_number, fund_cash_start=player.group.fund_cash_start, market_open_start=player.group.market_open_start, ) class EntryDecision(Page): form_model = 'player' form_fields = ['entry_choice'] timeout_seconds = 120 @staticmethod def is_displayed(player: Player): return player.can_invest @staticmethod def before_next_page(player: Player, timeout_happened): if timeout_happened: player.entry_choice = "keep" player.entry_timed_out = True class EntryWaitPage(WaitPage): after_all_players_arrive = process_entries @staticmethod def is_displayed(player: Player): group = player.group return player.group.market_open_start class EntryDebug(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): group = player.group return C.DEBUG and player.group.market_open_start @staticmethod def vars_for_template(player: Player): session = player.session subsession = player.subsession group = player.group group = player.group group_players = group.get_players() rows = [] for p in group_players: if p.invested_this_period: expected_interest_base = ( p.cash_start - C.INVESTMENT_AMOUNT ) else: expected_interest_base = p.cash_start cash_math_ok = ( abs( p.cash_interest_base - expected_interest_base ) < 0.001 ) rows.append( dict( link_number=p.id_in_subsession, market_position=p.id_in_group, eligibility_period=p.eligibility_period, can_invest=p.can_invest, entry_choice=p.entry_choice, entry_timed_out=p.entry_timed_out, invested_this_period=p.invested_this_period, cash_start=p.cash_start, cash_interest_base=p.cash_interest_base, expected_interest_base=expected_interest_base, cash_math_ok=cash_math_ok, ) ) rows = sorted( rows, key=lambda x: x['link_number'] ) counted_investments = sum( 1 for p in group_players if p.invested_this_period ) investment_count_ok = ( counted_investments == group.num_new_investments ) expected_investment_total = ( group.num_new_investments * C.INVESTMENT_AMOUNT ) investment_total_ok = ( abs( group.new_investment_total - expected_investment_total ) < 0.001 ) expected_fund_after_entries = ( group.fund_cash_start + group.new_investment_total ) fund_cash_ok = ( abs( group.fund_cash_after_entries - expected_fund_after_entries ) < 0.001 ) all_player_cash_ok = all( row['cash_math_ok'] for row in rows ) entry_stage_ok = ( investment_count_ok and investment_total_ok and fund_cash_ok and all_player_cash_ok ) return dict( rows=rows, market_number=group.id_in_subsession, round_number=player.round_number, fund_cash_start=group.fund_cash_start, num_new_investments=group.num_new_investments, counted_investments=counted_investments, new_investment_total=group.new_investment_total, expected_investment_total=expected_investment_total, fund_cash_after_entries=group.fund_cash_after_entries, expected_fund_after_entries=expected_fund_after_entries, investment_count_ok=investment_count_ok, investment_total_ok=investment_total_ok, fund_cash_ok=fund_cash_ok, all_player_cash_ok=all_player_cash_ok, entry_stage_ok=entry_stage_ok, ) class RedemptionDecision(Page): form_model = 'player' form_fields = ['redemption_choice'] timeout_seconds = 120 @staticmethod def is_displayed(player: Player): return player.can_redeem @staticmethod def before_next_page(player: Player, timeout_happened): if timeout_happened: player.redemption_choice = "stay" player.redemption_timed_out = True class RedemptionWaitPage(WaitPage): @staticmethod def is_displayed(player: Player): group = player.group return player.group.market_open_start class PredictionDecision(Page): form_model = 'player' form_fields = ['prediction_new_investments', 'prediction_redemption_requests', 'prediction_fund_closes', 'prediction_own_redemption_paid'] timeout_seconds = 120 @staticmethod def is_displayed(player: Player): group = player.group return ( player.round_number in C.PREDICTION_PERIODS and player.group.market_open_start ) @staticmethod def vars_for_template(player: Player): own_prediction_applicable = ( player.currently_invested_end ) return dict( own_prediction_applicable=own_prediction_applicable, ) @staticmethod def before_next_page(player: Player, timeout_happened): player.prediction_own_question_shown = ( player.currently_invested_end ) if timeout_happened: player.prediction_timed_out = True @staticmethod def error_message(player: Player, values): if ( player.currently_invested_end and not values.get('prediction_own_redemption_paid') ): return "Please answer the final prediction question." class PredictionWaitPage(WaitPage): @staticmethod def is_displayed(player: Player): group = player.group return ( player.round_number in C.PREDICTION_PERIODS and player.group.market_open_start ) class PredictionDebug(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): group = player.group return ( C.DEBUG and player.round_number in C.PREDICTION_PERIODS and player.group.market_open_start ) @staticmethod def vars_for_template(player: Player): session = player.session subsession = player.subsession group = player.group group = player.group group_players = group.get_players() rows = [] for p in group_players: q1 = p.field_maybe_none( 'prediction_new_investments' ) q2 = p.field_maybe_none( 'prediction_redemption_requests' ) q3 = p.field_maybe_none( 'prediction_fund_closes' ) q4 = p.field_maybe_none( 'prediction_own_redemption_paid' ) q1_answered = ( q1 is not None ) q2_answered = ( q2 is not None ) q3_answered = ( q3 in ['yes', 'no'] ) q4_answered = ( q4 in ['yes', 'no'] ) rows.append( dict( link_number=p.id_in_subsession, currently_invested=p.currently_invested_end, own_question_shown=p.prediction_own_question_shown, timed_out=p.prediction_timed_out, q1=q1, q2=q2, q3=q3, q4=q4, q1_answered=q1_answered, q2_answered=q2_answered, q3_answered=q3_answered, q4_answered=q4_answered, ) ) rows = sorted( rows, key=lambda x: x['link_number'] ) return dict( rows=rows, round_number=player.round_number, target_period=player.round_number + 1, market_number=group.id_in_subsession, ) class ProcessingWaitPage(WaitPage): after_all_players_arrive = process_redemptions_and_score @staticmethod def is_displayed(player: Player): group = player.group return player.group.market_open_start class PredictionScoringDebug(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): group = player.group return ( C.DEBUG and (player.round_number - 1) in C.PREDICTION_PERIODS and player.group.market_open_start ) @staticmethod def vars_for_template(player: Player): session = player.session subsession = player.subsession group = player.group participant = player.participant group = player.group current_players = group.get_players() prediction_round = ( player.round_number - 1 ) rows = [] for current_player in current_players: prediction_player = ( current_player.in_round( prediction_round ) ) # -------------------------------------------------- # SAFELY READ PREDICTION RESPONSES # # Blank responses are legitimate if a participant # timed out or if Q4 was not applicable. # -------------------------------------------------- q1_prediction = ( prediction_player.field_maybe_none( 'prediction_new_investments' ) ) q2_prediction = ( prediction_player.field_maybe_none( 'prediction_redemption_requests' ) ) q3_prediction = ( prediction_player.field_maybe_none( 'prediction_fund_closes' ) ) q4_prediction = ( prediction_player.field_maybe_none( 'prediction_own_redemption_paid' ) ) # -------------------------------------------------- # SAFELY READ REALIZED OUTCOMES # # Q4 actual can legitimately remain blank for # participants who were not shown Q4. # -------------------------------------------------- q1_actual = ( prediction_player.field_maybe_none( 'prediction_new_investments_actual' ) ) q2_actual = ( prediction_player.field_maybe_none( 'prediction_redemption_requests_actual' ) ) q3_actual = ( prediction_player.field_maybe_none( 'prediction_fund_closes_actual' ) ) q4_actual = ( prediction_player.field_maybe_none( 'prediction_own_redemption_paid_actual' ) ) # -------------------------------------------------- # QUESTION 1 # -------------------------------------------------- q1_answered = ( q1_prediction is not None ) # -------------------------------------------------- # QUESTION 2 # -------------------------------------------------- q2_answered = ( q2_prediction is not None ) # -------------------------------------------------- # QUESTION 3 # -------------------------------------------------- q3_answered = ( q3_prediction in ['yes', 'no'] ) # -------------------------------------------------- # QUESTION 4 # -------------------------------------------------- q4_shown = ( prediction_player.prediction_own_question_shown ) q4_answered = ( q4_prediction in ['yes', 'no'] ) # -------------------------------------------------- # OVERALL RESPONSE COUNT # -------------------------------------------------- eligible_response_count = sum([ prediction_player.prediction_new_investments_bonus_eligible, prediction_player.prediction_redemption_requests_bonus_eligible, prediction_player.prediction_fund_closes_bonus_eligible, prediction_player.prediction_own_redemption_paid_bonus_eligible, ]) correct_response_count = sum([ ( prediction_player.prediction_new_investments_bonus_eligible and prediction_player.prediction_new_investments_correct ), ( prediction_player.prediction_redemption_requests_bonus_eligible and prediction_player.prediction_redemption_requests_correct ), ( prediction_player.prediction_fund_closes_bonus_eligible and prediction_player.prediction_fund_closes_correct ), ( prediction_player.prediction_own_redemption_paid_bonus_eligible and prediction_player.prediction_own_redemption_paid_correct ), ]) rows.append( dict( link_number=current_player.id_in_subsession, target_occurred=prediction_player.prediction_target_period_occurred, timed_out=prediction_player.prediction_timed_out, # Q1 q1_answered=q1_answered, q1_prediction=q1_prediction, q1_actual=q1_actual, q1_eligible=prediction_player.prediction_new_investments_bonus_eligible, q1_correct=prediction_player.prediction_new_investments_correct, # Q2 q2_answered=q2_answered, q2_prediction=q2_prediction, q2_actual=q2_actual, q2_eligible=prediction_player.prediction_redemption_requests_bonus_eligible, q2_correct=prediction_player.prediction_redemption_requests_correct, # Q3 q3_answered=q3_answered, q3_prediction=q3_prediction, q3_actual=q3_actual, q3_eligible=prediction_player.prediction_fund_closes_bonus_eligible, q3_correct=prediction_player.prediction_fund_closes_correct, # Q4 q4_shown=q4_shown, q4_answered=q4_answered, q4_prediction=q4_prediction, q4_actual=q4_actual, q4_eligible=prediction_player.prediction_own_redemption_paid_bonus_eligible, q4_correct=prediction_player.prediction_own_redemption_paid_correct, eligible_response_count=eligible_response_count, correct_response_count=correct_response_count, ) ) rows = sorted( rows, key=lambda x: x['link_number'] ) # -------------------------------------------------- # MARKET-LEVEL REALIZED OUTCOMES # -------------------------------------------------- actual_new_investments = ( group.num_new_investments ) actual_redemption_requests = ( group.num_redemption_requests ) actual_fund_closes = ( 'yes' if group.collapsed_this_period else 'no' ) # -------------------------------------------------- # BASIC SCORING CHECKS # -------------------------------------------------- all_target_flags_ok = all( row['target_occurred'] for row in rows ) q1_actuals_ok = all( row['q1_actual'] == actual_new_investments for row in rows ) q2_actuals_ok = all( row['q2_actual'] == actual_redemption_requests for row in rows ) q3_actuals_ok = all( row['q3_actual'] == actual_fund_closes for row in rows ) overall_scoring_ok = ( all_target_flags_ok and q1_actuals_ok and q2_actuals_ok and q3_actuals_ok ) return dict( rows=rows, prediction_round=prediction_round, target_round=player.round_number, market_number=group.id_in_subsession, actual_new_investments=actual_new_investments, actual_redemption_requests=actual_redemption_requests, actual_fund_closes=actual_fund_closes, all_target_flags_ok=all_target_flags_ok, q1_actuals_ok=q1_actuals_ok, q2_actuals_ok=q2_actuals_ok, q3_actuals_ok=q3_actuals_ok, overall_scoring_ok=overall_scoring_ok, ) class RedemptionDebug(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): group = player.group return C.DEBUG and player.group.market_open_start @staticmethod def vars_for_template(player: Player): session = player.session subsession = player.subsession group = player.group group = player.group group_players = group.get_players() rows = [] for p in group_players: rows.append( dict( link_number=p.id_in_subsession, entry_period=p.entry_period, tie_priority=p.tie_priority, can_redeem=p.can_redeem, redemption_choice=p.redemption_choice, timed_out=p.redemption_timed_out, requested=p.requested_redemption, processing_order=p.redemption_processing_order, redemption_amount=p.redemption_amount_start, paid=p.redemption_paid, payment=p.redemption_payment, shortfall_trigger=p.shortfall_trigger, settlement_payment=p.settlement_payment, currently_invested_end=p.currently_invested_end, ) ) rows = sorted( rows, key=lambda x: x['link_number'] ) expected_cash_after_redemptions = ( group.fund_cash_after_entries - group.successful_redemption_total ) cash_math_ok = ( abs( group.fund_cash_after_redemptions - expected_cash_after_redemptions ) < 0.001 ) request_count_ok = ( group.num_redemption_requests == sum( 1 for p in group_players if p.requested_redemption ) ) paid_count_ok = ( group.num_redemptions_paid == sum( 1 for p in group_players if p.redemption_paid ) ) overall_ok = ( cash_math_ok and request_count_ok and paid_count_ok ) return dict( rows=rows, round_number=player.round_number, market_number=group.id_in_subsession, fund_cash_after_entries=group.fund_cash_after_entries, num_redemption_requests=group.num_redemption_requests, num_redemptions_paid=group.num_redemptions_paid, successful_redemption_total=group.successful_redemption_total, fund_cash_after_redemptions=group.fund_cash_after_redemptions, expected_cash_after_redemptions=expected_cash_after_redemptions, collapsed_this_period=group.collapsed_this_period, shortfall_request_amount=group.shortfall_request_amount, num_investors_settled=group.num_investors_settled, settlement_share=group.settlement_share, settlement_total=group.settlement_total, request_count_ok=request_count_ok, paid_count_ok=paid_count_ok, cash_math_ok=cash_math_ok, overall_ok=overall_ok, ) class PeriodEndWaitPage(WaitPage): after_all_players_arrive = process_period_end @staticmethod def is_displayed(player: Player): group = player.group if player.round_number == 1: return True previous_group = player.group.in_round( player.round_number - 1 ) return previous_group.continues_to_next_period class PeriodEndDebug(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): group = player.group if not C.DEBUG: return False if player.round_number == 1: return True previous_group = player.group.in_round( player.round_number - 1 ) return previous_group.continues_to_next_period @staticmethod def vars_for_template(player: Player): session = player.session subsession = player.subsession group = player.group group = player.group group_players = group.get_players() rows = [] for p in group_players: expected_interest = ( p.cash_interest_base * C.CASH_RETURN_RATE ) expected_cash_end = ( p.cash_interest_base + expected_interest + p.redemption_payment + p.settlement_payment ) cash_interest_ok = ( abs( p.cash_interest - expected_interest ) < 0.001 ) cash_end_ok = ( abs( p.cash_end - expected_cash_end ) < 0.001 ) if ( p.currently_invested_end and not group.collapsed_this_period ): expected_holding_periods = ( p.holding_periods_start + 1 ) expected_redemption_amount = ( C.INVESTMENT_AMOUNT + ( C.REDEMPTION_INCREMENT * expected_holding_periods ) ) else: expected_holding_periods = ( p.holding_periods_start ) expected_redemption_amount = ( p.redemption_amount_start ) holding_ok = ( p.holding_periods_end == expected_holding_periods ) redemption_amount_ok = ( abs( p.redemption_amount_end - expected_redemption_amount ) < 0.001 ) player_ok = ( cash_interest_ok and cash_end_ok and holding_ok and redemption_amount_ok ) rows.append( dict( link_number=p.id_in_subsession, invested_this_period=p.invested_this_period, currently_invested_end=p.currently_invested_end, cash_start=p.cash_start, cash_interest_base=p.cash_interest_base, cash_interest=p.cash_interest, redemption_payment=p.redemption_payment, settlement_payment=p.settlement_payment, cash_end=p.cash_end, holding_periods_start=p.holding_periods_start, holding_periods_end=p.holding_periods_end, redemption_amount_start=p.redemption_amount_start, redemption_amount_end=p.redemption_amount_end, cash_interest_ok=cash_interest_ok, cash_end_ok=cash_end_ok, holding_ok=holding_ok, redemption_amount_ok=redemption_amount_ok, player_ok=player_ok, ) ) rows = sorted( rows, key=lambda x: x['link_number'] ) if group.collapsed_this_period: expected_fund_interest = 0 expected_fund_end = 0 else: expected_fund_interest = ( group.fund_cash_after_redemptions * C.FUND_RETURN_RATE ) expected_fund_end = ( group.fund_cash_after_redemptions + expected_fund_interest ) fund_interest_ok = ( abs( group.fund_interest - expected_fund_interest ) < 0.001 ) fund_end_ok = ( abs( group.fund_cash_end - expected_fund_end ) < 0.001 ) all_players_ok = all( row['player_ok'] for row in rows ) overall_ok = ( all_players_ok and fund_interest_ok and fund_end_ok ) return dict( rows=rows, round_number=player.round_number, market_number=group.id_in_subsession, collapsed_this_period=group.collapsed_this_period, fund_cash_after_redemptions=group.fund_cash_after_redemptions, fund_interest=group.fund_interest, fund_cash_after_interest=group.fund_cash_after_interest, fund_cash_end=group.fund_cash_end, expected_fund_interest=expected_fund_interest, expected_fund_end=expected_fund_end, fund_interest_ok=fund_interest_ok, fund_end_ok=fund_end_ok, all_players_ok=all_players_ok, overall_ok=overall_ok, ) class ContinuationWaitPage(WaitPage): after_all_players_arrive = process_continuation_and_store @staticmethod def is_displayed(player: Player): group = player.group if player.round_number == 1: return True previous_group = player.group.in_round( player.round_number - 1 ) return previous_group.continues_to_next_period class ContinuationDebug(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): group = player.group if not C.DEBUG: return False if player.round_number == 1: return True previous_group = player.group.in_round( player.round_number - 1 ) return previous_group.continues_to_next_period @staticmethod def vars_for_template(player: Player): session = player.session subsession = player.subsession group = player.group group = player.group players = group.get_players() rows = [] for p in players: rows.append( dict( link_number=p.id_in_subsession, cash_start=p.cash_start, cash_interest=p.cash_interest, redemption_payment=p.redemption_payment, settlement_payment=p.settlement_payment, cash_end=p.cash_end, currently_invested_end=p.currently_invested_end, ) ) rows = sorted( rows, key=lambda x: x['link_number'] ) # -------------------------------------------------- # EXPECTED CONTINUATION LOGIC # -------------------------------------------------- if group.collapsed_this_period: if group.round_number < C.FIRST_RANDOM_END_PERIOD: expected_continuation = True expected_final_period = False else: expected_continuation = False expected_final_period = True elif group.passive_period: if group.round_number >= group.scheduled_end_period: expected_continuation = False expected_final_period = True else: expected_continuation = True expected_final_period = False else: if group.round_number >= group.scheduled_end_period: expected_continuation = False expected_final_period = True else: expected_continuation = True expected_final_period = False continuation_ok = ( group.continues_to_next_period == expected_continuation ) final_period_ok = ( group.final_period == expected_final_period ) # -------------------------------------------------- # MARKET-OPEN LOGIC # -------------------------------------------------- if group.collapsed_this_period: expected_market_open_end = False elif group.passive_period: expected_market_open_end = False elif group.final_period: expected_market_open_end = False else: expected_market_open_end = True market_open_ok = ( group.market_open_end == expected_market_open_end ) overall_ok = ( continuation_ok and final_period_ok and market_open_ok ) return dict( rows=rows, round_number=group.round_number, market_number=group.id_in_subsession, scheduled_end_period=group.scheduled_end_period, market_open_start=group.market_open_start, market_open_end=group.market_open_end, passive_period=group.passive_period, collapsed_this_period=group.collapsed_this_period, continues_to_next_period=group.continues_to_next_period, ended_this_period=group.ended_this_period, end_reason=group.end_reason, final_period=group.final_period, final_reason=group.final_reason, fund_cash_after_interest=group.fund_cash_after_interest, fund_cash_end=group.fund_cash_end, num_investors_settled=group.num_investors_settled, settlement_share=group.settlement_share, settlement_total=group.settlement_total, expected_continuation=expected_continuation, expected_final_period=expected_final_period, expected_market_open_end=expected_market_open_end, continuation_ok=continuation_ok, final_period_ok=final_period_ok, market_open_ok=market_open_ok, overall_ok=overall_ok, ) class PeriodResult(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): group = player.group if player.round_number == 1: return True previous_group = player.group.in_round( player.round_number - 1 ) return previous_group.continues_to_next_period @staticmethod def get_timeout_seconds(player: Player): return 20 class FinalOutcomeDebug(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): group = player.group return ( C.DEBUG and player.group.final_period ) @staticmethod def vars_for_template(player: Player): session = player.session subsession = player.subsession group = player.group participant = player.participant group = player.group players = group.get_players() rows = [] all_final_storage_ok = True all_bonus_ok = True for p in players: # ================================================== # STORED FINAL MARKET OUTCOME # ================================================== stored_fund_end_round = ( p.participant.vars.get( "fund_end_round" ) ) stored_fund_end_reason = ( p.participant.vars.get( "fund_end_reason" ) ) stored_final_round = ( p.participant.vars.get( "experiment_final_round" ) ) stored_final_reason = ( p.participant.vars.get( "experiment_final_reason" ) ) stored_final_cash = ( p.participant.vars.get( "market_final_cash_ecu" ) ) stored_market_payment = ( p.participant.vars.get( "market_payment_dollars" ) ) market_payment_field = ( p.field_maybe_none( "market_payment_dollars" ) ) # -------------------------------------------------- # EXPECTED MARKET PAYMENT # # 20 ECU = $1 # -------------------------------------------------- expected_market_payment = ( p.cash_end / C.ECU_PER_DOLLAR ) # -------------------------------------------------- # MARKET PAYMENT STORAGE CHECK # -------------------------------------------------- market_payment_ok = ( stored_market_payment is not None and market_payment_field is not None and abs( stored_market_payment - market_payment_field ) < 0.000000001 and abs( stored_market_payment - expected_market_payment ) < 0.000000001 ) # -------------------------------------------------- # FINAL OUTCOME STORAGE CHECK # -------------------------------------------------- final_storage_ok = ( stored_final_round == group.round_number and stored_final_reason == group.final_reason and stored_final_cash == p.cash_end and market_payment_ok ) # -------------------------------------------------- # FUND-END STORAGE CHECK # # If final period is passive, fund must have # closed earlier from insufficient cash. # # Otherwise the active fund ends in this period. # -------------------------------------------------- if group.passive_period: fund_storage_ok = ( stored_fund_end_round is not None and stored_fund_end_round < group.round_number and stored_fund_end_reason == "insufficient_cash" ) else: fund_storage_ok = ( stored_fund_end_round == group.round_number and stored_fund_end_reason == group.end_reason ) if not ( final_storage_ok and fund_storage_ok ): all_final_storage_ok = False # ================================================== # RECONSTRUCT ALL BONUS-ELIGIBLE RESPONSES # ================================================== eligible_responses = [] eligible_targets_ok = True for prediction_round in C.PREDICTION_PERIODS: prediction_player = ( p.in_round( prediction_round ) ) target_occurred = ( prediction_player.prediction_target_period_occurred ) question_data = [ ( "Q1", prediction_player.prediction_new_investments_bonus_eligible, prediction_player.prediction_new_investments_correct, ), ( "Q2", prediction_player.prediction_redemption_requests_bonus_eligible, prediction_player.prediction_redemption_requests_correct, ), ( "Q3", prediction_player.prediction_fund_closes_bonus_eligible, prediction_player.prediction_fund_closes_correct, ), ( "Q4", prediction_player.prediction_own_redemption_paid_bonus_eligible, prediction_player.prediction_own_redemption_paid_correct, ), ] for ( question, bonus_eligible, correct ) in question_data: if bonus_eligible: eligible_responses.append( ( prediction_round, question, correct, ) ) # Any bonus-eligible response must # correspond to a target period that # actually occurred. if not target_occurred: eligible_targets_ok = False reconstructed_num_eligible = ( len( eligible_responses ) ) # ================================================== # CHECK STORED LOTTERY RESULT # ================================================== stored_num_eligible = ( p.prediction_bonus_num_eligible ) selected_round = ( p.prediction_bonus_selected_round ) selected_question = ( p.prediction_bonus_selected_question ) selected_correct = ( p.prediction_bonus_selected_correct ) bonus_amount = ( p.prediction_bonus_amount ) count_ok = ( stored_num_eligible == reconstructed_num_eligible ) # -------------------------------------------------- # CASE A: # AT LEAST ONE ELIGIBLE RESPONSE # -------------------------------------------------- if eligible_responses: selected_matches = [ response for response in eligible_responses if ( response[0] == selected_round and response[1] == selected_question ) ] if selected_matches: reconstructed_selected_correct = ( selected_matches[0][2] ) selection_ok = ( selected_correct == reconstructed_selected_correct ) else: reconstructed_selected_correct = False selection_ok = False expected_bonus = ( C.PREDICTION_BONUS if reconstructed_selected_correct else 0 ) bonus_amount_ok = ( bonus_amount == expected_bonus ) # -------------------------------------------------- # CASE B: # NO ELIGIBLE RESPONSES # -------------------------------------------------- else: selection_ok = ( selected_round == 0 and selected_question == "" and selected_correct is False ) expected_bonus = 0 bonus_amount_ok = ( bonus_amount == 0 ) # -------------------------------------------------- # PARTICIPANT.VARS COPY CHECK # -------------------------------------------------- bonus_storage_ok = ( p.participant.vars.get( "prediction_bonus_num_eligible" ) == p.prediction_bonus_num_eligible and p.participant.vars.get( "prediction_bonus_selected_round" ) == p.prediction_bonus_selected_round and p.participant.vars.get( "prediction_bonus_selected_question" ) == p.prediction_bonus_selected_question and p.participant.vars.get( "prediction_bonus_selected_correct" ) == p.prediction_bonus_selected_correct and p.participant.vars.get( "prediction_bonus_amount" ) == p.prediction_bonus_amount ) participant_bonus_ok = ( count_ok and eligible_targets_ok and selection_ok and bonus_amount_ok and bonus_storage_ok ) if not participant_bonus_ok: all_bonus_ok = False # ================================================== # BUILD DEBUG ROW # ================================================== rows.append( dict( link_number=p.id_in_subsession, final_cash=p.cash_end, market_payment_dollars=( stored_market_payment ), fund_end_round=( stored_fund_end_round ), fund_end_reason=( stored_fund_end_reason ), final_round=( stored_final_round ), final_reason=( stored_final_reason ), final_storage_ok=( final_storage_ok and fund_storage_ok ), reconstructed_num_eligible=( reconstructed_num_eligible ), stored_num_eligible=( stored_num_eligible ), selected_round=( selected_round ), selected_question=( selected_question ), selected_correct=( selected_correct ), bonus_amount=( bonus_amount ), eligible_targets_ok=( eligible_targets_ok ), participant_bonus_ok=( participant_bonus_ok ), ) ) rows = sorted( rows, key=lambda x: x["link_number"] ) overall_ok = ( all_final_storage_ok and all_bonus_ok ) return dict( rows=rows, round_number=( group.round_number ), final_reason=( group.final_reason ), all_final_storage_ok=( all_final_storage_ok ), all_bonus_ok=( all_bonus_ok ), overall_ok=( overall_ok ), ) class BRETDecision(Page): form_model = 'player' form_fields = ['bret_choice'] @staticmethod def is_displayed(player: Player): group = player.group return player.group.final_period @staticmethod def vars_for_template(player: Player): return dict( boxes=list( range( 1, C.BRET_NUM_BOXES + 1 ) ) ) @staticmethod def before_next_page(player: Player, timeout_happened): process_bret(player) class BRETDebug(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): group = player.group return ( C.DEBUG and player.group.final_period ) @staticmethod def vars_for_template(player: Player): participant = player.participant choice = player.bret_choice monster_box = ( player.bret_monster_box ) monster_collected = ( player.bret_monster_collected ) payment = ( player.bret_payment_dollars ) # -------------------------------------------------- # RECONSTRUCT EXPECTED OUTCOME # -------------------------------------------------- choice_ok = ( choice >= 0 and choice <= C.BRET_NUM_BOXES ) monster_box_ok = ( monster_box >= 1 and monster_box <= C.BRET_NUM_BOXES ) expected_monster_collected = ( choice >= monster_box ) monster_logic_ok = ( monster_collected == expected_monster_collected ) if expected_monster_collected: expected_payment = 0 else: expected_payment = ( choice * C.BRET_DOLLARS_PER_BOX ) payment_ok = ( abs( payment - expected_payment ) < 0.000000001 ) # -------------------------------------------------- # PARTICIPANT.VARS STORAGE CHECK # -------------------------------------------------- storage_ok = ( player.participant.vars.get( "bret_choice" ) == choice and player.participant.vars.get( "bret_monster_box" ) == monster_box and player.participant.vars.get( "bret_monster_collected" ) == monster_collected and player.participant.vars.get( "bret_payment_dollars" ) == payment ) overall_ok = ( choice_ok and monster_box_ok and monster_logic_ok and payment_ok and storage_ok ) return dict( choice=choice, monster_box=monster_box, monster_collected=( monster_collected ), payment=payment, expected_payment=( expected_payment ), choice_ok=choice_ok, monster_box_ok=( monster_box_ok ), monster_logic_ok=( monster_logic_ok ), payment_ok=payment_ok, storage_ok=storage_ok, overall_ok=overall_ok, ) class BRETResult(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): group = player.group return player.group.final_period class FinalPaymentDebug(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): group = player.group return ( C.DEBUG and player.group.final_period ) @staticmethod def vars_for_template(player: Player): participant = player.participant calculate_final_payment(player) market_payment = ( player.participant.vars.get( "market_payment_dollars", 0 ) ) prediction_bonus = ( player.participant.vars.get( "prediction_bonus_amount", 0 ) ) bret_payment = ( player.participant.vars.get( "bret_payment_dollars", 0 ) ) stored_show_up_fee = ( player.participant.vars.get( "show_up_fee_dollars" ) ) stored_total = ( player.participant.vars.get( "total_payment_dollars" ) ) expected_total = ( C.SHOW_UP_FEE + market_payment + prediction_bonus + bret_payment ) show_up_ok = ( stored_show_up_fee == C.SHOW_UP_FEE ) market_ok = ( market_payment == player.market_payment_dollars ) prediction_ok = ( prediction_bonus == player.prediction_bonus_amount ) bret_ok = ( bret_payment == player.bret_payment_dollars ) total_ok = ( abs( stored_total - expected_total ) < 0.000000001 and abs( player.total_payment_dollars - expected_total ) < 0.000000001 ) overall_ok = ( show_up_ok and market_ok and prediction_ok and bret_ok and total_ok ) return dict( show_up_fee=C.SHOW_UP_FEE, market_payment=market_payment, prediction_bonus=prediction_bonus, bret_payment=bret_payment, stored_total=stored_total, expected_total=expected_total, show_up_ok=show_up_ok, market_ok=market_ok, prediction_ok=prediction_ok, bret_ok=bret_ok, total_ok=total_ok, overall_ok=overall_ok, ) class FinalPayment(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): group = player.group return player.group.final_period @staticmethod def vars_for_template(player: Player): participant = player.participant calculate_final_payment(player) market_payment = ( player.participant.vars.get( "market_payment_dollars", 0 ) ) prediction_bonus = ( player.participant.vars.get( "prediction_bonus_amount", 0 ) ) bret_payment = ( player.participant.vars.get( "bret_payment_dollars", 0 ) ) final_market_ecu = ( player.participant.vars.get( "market_final_cash_ecu", 0 ) ) selected_prediction_round = ( player.participant.vars.get( "prediction_bonus_selected_round", 0 ) ) selected_prediction_question = ( player.participant.vars.get( "prediction_bonus_selected_question", "" ) ) selected_prediction_correct = ( player.participant.vars.get( "prediction_bonus_selected_correct", False ) ) return dict( show_up_fee=C.SHOW_UP_FEE, ecu_per_dollar=C.ECU_PER_DOLLAR, final_market_ecu=final_market_ecu, market_payment=market_payment, prediction_bonus=prediction_bonus, bret_payment=bret_payment, total_payment=( player.total_payment_dollars ), selected_prediction_round=( selected_prediction_round ), selected_prediction_question=( selected_prediction_question ), selected_prediction_correct=( selected_prediction_correct ), ) page_sequence = [PeriodStartWaitPage, SetupDebug, EntryDecision, EntryWaitPage, EntryDebug, RedemptionDecision, RedemptionWaitPage, PredictionDecision, PredictionWaitPage, PredictionDebug, ProcessingWaitPage, PredictionScoringDebug, RedemptionDebug, PeriodEndWaitPage, PeriodEndDebug, ContinuationWaitPage, ContinuationDebug, PeriodResult, FinalOutcomeDebug, BRETDecision, BRETDebug, BRETResult, FinalPaymentDebug, FinalPayment]