Add hook debug tooling and refine RT3 atlas
This commit is contained in:
parent
860d1aed90
commit
57bf0666e0
38 changed files with 14437 additions and 873 deletions
957
crates/rrt-model/src/finance.rs
Normal file
957
crates/rrt-model/src/finance.rs
Normal file
|
|
@ -0,0 +1,957 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum GrowthSetting {
|
||||
#[default]
|
||||
Neutral,
|
||||
ExpansionBias,
|
||||
DividendSuppressed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BondPosition {
|
||||
pub principal: i64,
|
||||
pub coupon_rate: f64,
|
||||
pub years_remaining: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BankruptcyReason {
|
||||
EarlyStress,
|
||||
DeepDistress,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AnnualReportMetric {
|
||||
NetProfits,
|
||||
RevenueAggregate,
|
||||
FuelCost,
|
||||
RevenuePerShare,
|
||||
EarningsPerShare,
|
||||
DividendPerShare,
|
||||
BookValuePerShare,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DebtNewsOutcome {
|
||||
RefinanceOnly,
|
||||
RefinanceAndBorrow,
|
||||
RefinanceAndPayDown,
|
||||
DebtPayoffOnly,
|
||||
NewBorrowingOnly,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct DebtRestructureSummary {
|
||||
pub retired_principal: i64,
|
||||
pub issued_principal: i64,
|
||||
}
|
||||
|
||||
impl DebtRestructureSummary {
|
||||
pub fn classify(self) -> Option<DebtNewsOutcome> {
|
||||
match (self.retired_principal > 0, self.issued_principal > 0) {
|
||||
(false, false) => None,
|
||||
(false, true) => Some(DebtNewsOutcome::NewBorrowingOnly),
|
||||
(true, false) => Some(DebtNewsOutcome::DebtPayoffOnly),
|
||||
(true, true) if self.retired_principal == self.issued_principal => {
|
||||
Some(DebtNewsOutcome::RefinanceOnly)
|
||||
}
|
||||
(true, true) if self.issued_principal > self.retired_principal => {
|
||||
Some(DebtNewsOutcome::RefinanceAndBorrow)
|
||||
}
|
||||
(true, true) => Some(DebtNewsOutcome::RefinanceAndPayDown),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AnnualFinanceDecision {
|
||||
NoAction,
|
||||
DeclareBankruptcy {
|
||||
reason: BankruptcyReason,
|
||||
},
|
||||
IssueBond {
|
||||
count: u32,
|
||||
principal_per_bond: i64,
|
||||
term_years: u8,
|
||||
},
|
||||
RepurchasePublicShares {
|
||||
share_count: u32,
|
||||
price_per_share: f64,
|
||||
},
|
||||
IssuePublicShares {
|
||||
share_count_per_tranche: u32,
|
||||
tranche_count: u32,
|
||||
price_per_share: f64,
|
||||
},
|
||||
AdjustDividend {
|
||||
old_rate: f64,
|
||||
new_rate: f64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AnnualFinanceEvaluation {
|
||||
pub decision: AnnualFinanceDecision,
|
||||
pub debt_restructure: DebtRestructureSummary,
|
||||
pub debt_news: Option<DebtNewsOutcome>,
|
||||
pub repurchased_share_count: u32,
|
||||
pub issued_share_count: u32,
|
||||
}
|
||||
|
||||
impl AnnualFinanceEvaluation {
|
||||
pub fn no_action() -> Self {
|
||||
Self {
|
||||
decision: AnnualFinanceDecision::NoAction,
|
||||
debt_restructure: DebtRestructureSummary::default(),
|
||||
debt_news: None,
|
||||
repurchased_share_count: 0,
|
||||
issued_share_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AnnualFinancePolicy {
|
||||
pub annual_mode: u8,
|
||||
pub build_103_plus: bool,
|
||||
pub bankruptcy_allowed: bool,
|
||||
pub bond_issuance_allowed: bool,
|
||||
pub stock_actions_allowed: bool,
|
||||
pub dividends_allowed: bool,
|
||||
pub growth_setting: GrowthSetting,
|
||||
pub stock_issue_cash_buffer: i64,
|
||||
}
|
||||
|
||||
impl Default for AnnualFinancePolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
annual_mode: 0x0c,
|
||||
build_103_plus: true,
|
||||
bankruptcy_allowed: true,
|
||||
bond_issuance_allowed: true,
|
||||
stock_actions_allowed: true,
|
||||
dividends_allowed: true,
|
||||
growth_setting: GrowthSetting::Neutral,
|
||||
stock_issue_cash_buffer: 30_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CompanyFinanceState {
|
||||
pub active: bool,
|
||||
pub years_since_founding: u8,
|
||||
pub years_since_last_bankruptcy: u8,
|
||||
pub current_cash: i64,
|
||||
pub current_company_value: i64,
|
||||
pub support_adjusted_share_price: f64,
|
||||
pub book_value_per_share: f64,
|
||||
pub current_fuel_cost: i64,
|
||||
pub current_dividend_per_share: f64,
|
||||
pub board_dividend_ceiling: f64,
|
||||
pub outstanding_share_count: u32,
|
||||
pub unassigned_share_count: u32,
|
||||
pub city_connection_bonus_latch: bool,
|
||||
pub linked_transit_service_latch: bool,
|
||||
pub chairman_buyback_factor: Option<f64>,
|
||||
pub recent_net_profits: [i64; 3],
|
||||
pub recent_revenue_totals: [i64; 3],
|
||||
pub recent_revenue_per_share: [f64; 3],
|
||||
pub recent_earnings_per_share: [f64; 3],
|
||||
pub recent_dividend_per_share: [f64; 3],
|
||||
pub bonds: Vec<BondPosition>,
|
||||
}
|
||||
|
||||
impl Default for CompanyFinanceState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active: true,
|
||||
years_since_founding: 5,
|
||||
years_since_last_bankruptcy: 20,
|
||||
current_cash: 0,
|
||||
current_company_value: 1_000_000,
|
||||
support_adjusted_share_price: 25.0,
|
||||
book_value_per_share: 20.0,
|
||||
current_fuel_cost: 0,
|
||||
current_dividend_per_share: 0.0,
|
||||
board_dividend_ceiling: 2.0,
|
||||
outstanding_share_count: 20_000,
|
||||
unassigned_share_count: 10_000,
|
||||
city_connection_bonus_latch: false,
|
||||
linked_transit_service_latch: false,
|
||||
chairman_buyback_factor: None,
|
||||
recent_net_profits: [10_000, 10_000, 10_000],
|
||||
recent_revenue_totals: [200_000, 180_000, 160_000],
|
||||
recent_revenue_per_share: [1.2, 1.1, 1.0],
|
||||
recent_earnings_per_share: [1.0, 0.9, 0.8],
|
||||
recent_dividend_per_share: [0.2, 0.2, 0.1],
|
||||
bonds: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CompanyFinanceState {
|
||||
pub const BOND_PRINCIPAL: i64 = 500_000;
|
||||
pub const BOND_TERM_YEARS: u8 = 30;
|
||||
pub const SHARE_LOT: u32 = 1_000;
|
||||
|
||||
pub fn total_debt_principal(&self) -> i64 {
|
||||
self.bonds.iter().map(|bond| bond.principal.max(0)).sum()
|
||||
}
|
||||
|
||||
pub fn highest_coupon_bond(&self) -> Option<BondPosition> {
|
||||
self.bonds
|
||||
.iter()
|
||||
.copied()
|
||||
.max_by(|left, right| left.coupon_rate.total_cmp(&right.coupon_rate))
|
||||
}
|
||||
|
||||
pub fn simulate_cash_after_full_bond_repayment(&self) -> i64 {
|
||||
self.current_cash - self.total_debt_principal()
|
||||
}
|
||||
|
||||
pub fn declare_bankruptcy(&mut self) {
|
||||
for bond in &mut self.bonds {
|
||||
bond.principal /= 2;
|
||||
}
|
||||
self.current_company_value /= 2;
|
||||
self.years_since_last_bankruptcy = 0;
|
||||
}
|
||||
|
||||
pub fn issue_bond(&mut self, coupon_rate: f64, count: u32) {
|
||||
for _ in 0..count {
|
||||
self.bonds.push(BondPosition {
|
||||
principal: Self::BOND_PRINCIPAL,
|
||||
coupon_rate,
|
||||
years_remaining: Self::BOND_TERM_YEARS,
|
||||
});
|
||||
self.current_cash += Self::BOND_PRINCIPAL;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn repurchase_public_shares(&mut self, share_count: u32, price_per_share: f64) {
|
||||
let repurchased = share_count.min(self.unassigned_share_count);
|
||||
self.unassigned_share_count -= repurchased;
|
||||
self.outstanding_share_count = self.outstanding_share_count.saturating_sub(repurchased);
|
||||
self.current_cash -= (repurchased as f64 * price_per_share).round() as i64;
|
||||
}
|
||||
|
||||
pub fn issue_public_shares(&mut self, share_count: u32, price_per_share: f64) {
|
||||
self.outstanding_share_count = self.outstanding_share_count.saturating_add(share_count);
|
||||
self.unassigned_share_count = self.unassigned_share_count.saturating_add(share_count);
|
||||
self.current_cash += (share_count as f64 * price_per_share).round() as i64;
|
||||
}
|
||||
|
||||
pub fn set_dividend_rate(&mut self, new_rate: f64) {
|
||||
self.current_dividend_per_share = new_rate.clamp(0.0, self.board_dividend_ceiling);
|
||||
}
|
||||
|
||||
pub fn read_recent_metric(
|
||||
&self,
|
||||
metric: AnnualReportMetric,
|
||||
years_ago: usize,
|
||||
) -> Option<f64> {
|
||||
match metric {
|
||||
AnnualReportMetric::FuelCost if years_ago == 0 => Some(self.current_fuel_cost as f64),
|
||||
AnnualReportMetric::BookValuePerShare if years_ago == 0 => Some(self.book_value_per_share),
|
||||
AnnualReportMetric::NetProfits => self
|
||||
.recent_net_profits
|
||||
.get(years_ago)
|
||||
.copied()
|
||||
.map(|value| value as f64),
|
||||
AnnualReportMetric::RevenueAggregate => self
|
||||
.recent_revenue_totals
|
||||
.get(years_ago)
|
||||
.copied()
|
||||
.map(|value| value as f64),
|
||||
AnnualReportMetric::RevenuePerShare => {
|
||||
self.recent_revenue_per_share.get(years_ago).copied()
|
||||
}
|
||||
AnnualReportMetric::EarningsPerShare => {
|
||||
self.recent_earnings_per_share.get(years_ago).copied()
|
||||
}
|
||||
AnnualReportMetric::DividendPerShare => {
|
||||
self.recent_dividend_per_share.get(years_ago).copied()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_recent_metric_window(
|
||||
&self,
|
||||
metric: AnnualReportMetric,
|
||||
years: usize,
|
||||
) -> Vec<f64> {
|
||||
(0..years)
|
||||
.filter_map(|years_ago| self.read_recent_metric(metric, years_ago))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn weighted_recent_metric(
|
||||
&self,
|
||||
metric: AnnualReportMetric,
|
||||
weights: &[f64],
|
||||
) -> Option<f64> {
|
||||
let mut numerator = 0.0;
|
||||
let mut denominator = 0.0;
|
||||
for (years_ago, weight) in weights.iter().copied().enumerate() {
|
||||
let value = self.read_recent_metric(metric, years_ago)?;
|
||||
numerator += value * weight;
|
||||
denominator += weight;
|
||||
}
|
||||
|
||||
(denominator > 0.0).then_some(numerator / denominator)
|
||||
}
|
||||
|
||||
pub fn apply_annual_decision(&mut self, decision: &AnnualFinanceDecision) {
|
||||
match *decision {
|
||||
AnnualFinanceDecision::NoAction => {}
|
||||
AnnualFinanceDecision::DeclareBankruptcy { .. } => self.declare_bankruptcy(),
|
||||
AnnualFinanceDecision::IssueBond {
|
||||
count,
|
||||
principal_per_bond: _,
|
||||
term_years: _,
|
||||
} => {
|
||||
let coupon = self
|
||||
.highest_coupon_bond()
|
||||
.map(|bond| bond.coupon_rate)
|
||||
.unwrap_or(0.10);
|
||||
self.issue_bond(coupon, count);
|
||||
}
|
||||
AnnualFinanceDecision::RepurchasePublicShares {
|
||||
share_count,
|
||||
price_per_share,
|
||||
} => self.repurchase_public_shares(share_count, price_per_share),
|
||||
AnnualFinanceDecision::IssuePublicShares {
|
||||
share_count_per_tranche,
|
||||
tranche_count,
|
||||
price_per_share,
|
||||
} => self.issue_public_shares(
|
||||
share_count_per_tranche.saturating_mul(tranche_count),
|
||||
price_per_share,
|
||||
),
|
||||
AnnualFinanceDecision::AdjustDividend { new_rate, .. } => {
|
||||
self.set_dividend_rate(new_rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FinanceSnapshot {
|
||||
pub policy: AnnualFinancePolicy,
|
||||
pub company: CompanyFinanceState,
|
||||
}
|
||||
|
||||
impl FinanceSnapshot {
|
||||
pub fn evaluate(&self) -> FinanceOutcome {
|
||||
let evaluation = evaluate_annual_finance_policy_detailed(&self.policy, &self.company);
|
||||
let mut post_company = self.company.clone();
|
||||
post_company.apply_annual_decision(&evaluation.decision);
|
||||
|
||||
FinanceOutcome {
|
||||
evaluation,
|
||||
post_company,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FinanceOutcome {
|
||||
pub evaluation: AnnualFinanceEvaluation,
|
||||
pub post_company: CompanyFinanceState,
|
||||
}
|
||||
|
||||
pub fn evaluate_annual_finance_policy(
|
||||
policy: &AnnualFinancePolicy,
|
||||
company: &CompanyFinanceState,
|
||||
) -> AnnualFinanceDecision {
|
||||
evaluate_annual_finance_policy_detailed(policy, company).decision
|
||||
}
|
||||
|
||||
pub fn evaluate_annual_finance_policy_detailed(
|
||||
policy: &AnnualFinancePolicy,
|
||||
company: &CompanyFinanceState,
|
||||
) -> AnnualFinanceEvaluation {
|
||||
if !company.active {
|
||||
return AnnualFinanceEvaluation::no_action();
|
||||
}
|
||||
|
||||
if should_bankrupt_early(policy, company) {
|
||||
return AnnualFinanceEvaluation {
|
||||
decision: AnnualFinanceDecision::DeclareBankruptcy {
|
||||
reason: BankruptcyReason::EarlyStress,
|
||||
},
|
||||
..AnnualFinanceEvaluation::no_action()
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(evaluation) = issue_bond_evaluation(policy, company) {
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
if let Some(evaluation) = repurchase_evaluation(policy, company) {
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
if should_bankrupt_deep_distress(policy, company) {
|
||||
return AnnualFinanceEvaluation {
|
||||
decision: AnnualFinanceDecision::DeclareBankruptcy {
|
||||
reason: BankruptcyReason::DeepDistress,
|
||||
},
|
||||
..AnnualFinanceEvaluation::no_action()
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(evaluation) = issue_stock_evaluation(policy, company) {
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
if let Some(evaluation) = dividend_evaluation(policy, company) {
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
AnnualFinanceEvaluation::no_action()
|
||||
}
|
||||
|
||||
fn should_bankrupt_early(policy: &AnnualFinancePolicy, company: &CompanyFinanceState) -> bool {
|
||||
if policy.annual_mode != 0x0c || !policy.bankruptcy_allowed {
|
||||
return false;
|
||||
}
|
||||
if company.years_since_last_bankruptcy < 13 || company.years_since_founding < 4 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let current_revenue = company.recent_revenue_totals[0];
|
||||
let stress_ladder: i64 = if current_revenue < 120_000 {
|
||||
-600_000
|
||||
} else if current_revenue < 230_000 {
|
||||
-1_100_000
|
||||
} else if current_revenue < 340_000 {
|
||||
-1_600_000
|
||||
} else {
|
||||
-2_000_000
|
||||
};
|
||||
|
||||
let failed_profit_years = company
|
||||
.recent_net_profits
|
||||
.iter()
|
||||
.filter(|profit| **profit <= 0)
|
||||
.count();
|
||||
let net_profit_sum: i64 = company.recent_net_profits.iter().sum();
|
||||
let share_price_floor = if failed_profit_years == 3 { 20.0 } else { 15.0 };
|
||||
let fuel_gate = (stress_ladder.abs() as f64 * 0.08).round() as i64;
|
||||
|
||||
failed_profit_years >= 2
|
||||
&& net_profit_sum <= -60_000
|
||||
&& company.support_adjusted_share_price >= share_price_floor
|
||||
&& company.current_fuel_cost >= fuel_gate
|
||||
}
|
||||
|
||||
fn should_bankrupt_deep_distress(
|
||||
policy: &AnnualFinancePolicy,
|
||||
company: &CompanyFinanceState,
|
||||
) -> bool {
|
||||
policy.bankruptcy_allowed
|
||||
&& company.current_cash < -300_000
|
||||
&& company.years_since_founding >= 3
|
||||
&& company.years_since_last_bankruptcy >= 5
|
||||
&& company.recent_net_profits.iter().all(|profit| *profit <= -20_000)
|
||||
}
|
||||
|
||||
fn issue_bond_evaluation(
|
||||
policy: &AnnualFinancePolicy,
|
||||
company: &CompanyFinanceState,
|
||||
) -> Option<AnnualFinanceEvaluation> {
|
||||
if !policy.bond_issuance_allowed {
|
||||
return None;
|
||||
}
|
||||
|
||||
let simulated_cash = company.simulate_cash_after_full_bond_repayment();
|
||||
let target_floor = if company.linked_transit_service_latch {
|
||||
-30_000
|
||||
} else {
|
||||
-250_000
|
||||
};
|
||||
if simulated_cash >= target_floor {
|
||||
return None;
|
||||
}
|
||||
|
||||
let shortfall = (target_floor - simulated_cash).max(0) as u64;
|
||||
let count = shortfall.div_ceil(CompanyFinanceState::BOND_PRINCIPAL as u64) as u32;
|
||||
let issued_principal = count.max(1) as i64 * CompanyFinanceState::BOND_PRINCIPAL;
|
||||
let debt_restructure = DebtRestructureSummary {
|
||||
retired_principal: 0,
|
||||
issued_principal,
|
||||
};
|
||||
|
||||
Some(AnnualFinanceEvaluation {
|
||||
decision: AnnualFinanceDecision::IssueBond {
|
||||
count: count.max(1),
|
||||
principal_per_bond: CompanyFinanceState::BOND_PRINCIPAL,
|
||||
term_years: CompanyFinanceState::BOND_TERM_YEARS,
|
||||
},
|
||||
debt_news: debt_restructure.classify(),
|
||||
debt_restructure,
|
||||
..AnnualFinanceEvaluation::no_action()
|
||||
})
|
||||
}
|
||||
|
||||
fn repurchase_evaluation(
|
||||
policy: &AnnualFinancePolicy,
|
||||
company: &CompanyFinanceState,
|
||||
) -> Option<AnnualFinanceEvaluation> {
|
||||
if !policy.stock_actions_allowed
|
||||
|| !company.city_connection_bonus_latch
|
||||
|| matches!(policy.growth_setting, GrowthSetting::DividendSuppressed)
|
||||
|| company.unassigned_share_count < CompanyFinanceState::SHARE_LOT
|
||||
|| company.current_company_value < 800_000
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut factor = company.chairman_buyback_factor.unwrap_or(1.0);
|
||||
if matches!(policy.growth_setting, GrowthSetting::ExpansionBias) {
|
||||
factor *= 1.6;
|
||||
}
|
||||
|
||||
let batch = CompanyFinanceState::SHARE_LOT;
|
||||
let affordability_gate = company.support_adjusted_share_price * factor * batch as f64 * 1.2;
|
||||
if company.current_cash < affordability_gate.round() as i64 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(AnnualFinanceEvaluation {
|
||||
decision: AnnualFinanceDecision::RepurchasePublicShares {
|
||||
share_count: batch,
|
||||
price_per_share: company.support_adjusted_share_price,
|
||||
},
|
||||
repurchased_share_count: batch,
|
||||
..AnnualFinanceEvaluation::no_action()
|
||||
})
|
||||
}
|
||||
|
||||
fn issue_stock_evaluation(
|
||||
policy: &AnnualFinancePolicy,
|
||||
company: &CompanyFinanceState,
|
||||
) -> Option<AnnualFinanceEvaluation> {
|
||||
if !policy.build_103_plus
|
||||
|| !policy.stock_actions_allowed
|
||||
|| !policy.bond_issuance_allowed
|
||||
|| company.bonds.len() < 2
|
||||
|| company.years_since_founding < 1
|
||||
|| company.support_adjusted_share_price < 22.0
|
||||
|| company.book_value_per_share <= 0.0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let highest_coupon = company.highest_coupon_bond()?;
|
||||
if company.current_cash >= highest_coupon.principal + policy.stock_issue_cash_buffer {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut tranche =
|
||||
((company.outstanding_share_count / 10) / CompanyFinanceState::SHARE_LOT)
|
||||
* CompanyFinanceState::SHARE_LOT;
|
||||
tranche = tranche.max(2_000);
|
||||
while tranche >= CompanyFinanceState::SHARE_LOT
|
||||
&& company.support_adjusted_share_price * tranche as f64 > 55_000.0
|
||||
{
|
||||
tranche -= CompanyFinanceState::SHARE_LOT;
|
||||
}
|
||||
if tranche < CompanyFinanceState::SHARE_LOT {
|
||||
return None;
|
||||
}
|
||||
|
||||
let price_to_book = company.support_adjusted_share_price / company.book_value_per_share;
|
||||
if price_to_book < required_price_to_book_ratio(highest_coupon.coupon_rate) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(AnnualFinanceEvaluation {
|
||||
decision: AnnualFinanceDecision::IssuePublicShares {
|
||||
share_count_per_tranche: tranche,
|
||||
tranche_count: 2,
|
||||
price_per_share: company.support_adjusted_share_price,
|
||||
},
|
||||
issued_share_count: tranche * 2,
|
||||
..AnnualFinanceEvaluation::no_action()
|
||||
})
|
||||
}
|
||||
|
||||
fn dividend_evaluation(
|
||||
policy: &AnnualFinancePolicy,
|
||||
company: &CompanyFinanceState,
|
||||
) -> Option<AnnualFinanceEvaluation> {
|
||||
if !policy.dividends_allowed || company.years_since_founding < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let weighted_target = company
|
||||
.weighted_recent_metric(AnnualReportMetric::EarningsPerShare, &[3.0, 2.0, 1.0])
|
||||
.unwrap_or(0.0);
|
||||
let mut target = weighted_target.max(0.0);
|
||||
|
||||
if company.unassigned_share_count < CompanyFinanceState::SHARE_LOT
|
||||
&& company.outstanding_share_count > 0
|
||||
&& company.current_cash > 0
|
||||
{
|
||||
target += company.current_cash as f64 / company.outstanding_share_count as f64;
|
||||
}
|
||||
|
||||
target = match policy.growth_setting {
|
||||
GrowthSetting::Neutral => target,
|
||||
GrowthSetting::ExpansionBias => target * 0.66,
|
||||
GrowthSetting::DividendSuppressed => 0.0,
|
||||
};
|
||||
target = quantize_tenths(target.clamp(0.0, company.board_dividend_ceiling));
|
||||
|
||||
if (target - company.current_dividend_per_share).abs() <= 0.1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(AnnualFinanceEvaluation {
|
||||
decision: AnnualFinanceDecision::AdjustDividend {
|
||||
old_rate: company.current_dividend_per_share,
|
||||
new_rate: target,
|
||||
},
|
||||
..AnnualFinanceEvaluation::no_action()
|
||||
})
|
||||
}
|
||||
|
||||
fn required_price_to_book_ratio(coupon_rate: f64) -> f64 {
|
||||
if coupon_rate <= 0.07 {
|
||||
1.3
|
||||
} else if coupon_rate <= 0.08 {
|
||||
1.2
|
||||
} else if coupon_rate <= 0.09 {
|
||||
1.1
|
||||
} else if coupon_rate <= 0.10 {
|
||||
0.95
|
||||
} else if coupon_rate <= 0.11 {
|
||||
0.8
|
||||
} else if coupon_rate <= 0.12 {
|
||||
0.62
|
||||
} else if coupon_rate <= 0.13 {
|
||||
0.5
|
||||
} else {
|
||||
0.35
|
||||
}
|
||||
}
|
||||
|
||||
fn quantize_tenths(value: f64) -> f64 {
|
||||
(value * 10.0).round() / 10.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn base_policy() -> AnnualFinancePolicy {
|
||||
AnnualFinancePolicy::default()
|
||||
}
|
||||
|
||||
fn base_company() -> CompanyFinanceState {
|
||||
CompanyFinanceState {
|
||||
bonds: vec![
|
||||
BondPosition {
|
||||
principal: 400_000,
|
||||
coupon_rate: 0.10,
|
||||
years_remaining: 10,
|
||||
},
|
||||
BondPosition {
|
||||
principal: 300_000,
|
||||
coupon_rate: 0.12,
|
||||
years_remaining: 12,
|
||||
},
|
||||
],
|
||||
..CompanyFinanceState::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn early_bankruptcy_precedes_other_actions() {
|
||||
let policy = base_policy();
|
||||
let company = CompanyFinanceState {
|
||||
current_fuel_cost: 90_000,
|
||||
support_adjusted_share_price: 21.0,
|
||||
recent_net_profits: [-30_000, -25_000, -20_000],
|
||||
recent_revenue_totals: [150_000, 140_000, 130_000],
|
||||
city_connection_bonus_latch: true,
|
||||
linked_transit_service_latch: true,
|
||||
..base_company()
|
||||
};
|
||||
|
||||
let decision = evaluate_annual_finance_policy(&policy, &company);
|
||||
assert_eq!(
|
||||
decision,
|
||||
AnnualFinanceDecision::DeclareBankruptcy {
|
||||
reason: BankruptcyReason::EarlyStress,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bond_issue_precedes_stock_issue_when_cash_window_fails() {
|
||||
let policy = base_policy();
|
||||
let company = CompanyFinanceState {
|
||||
current_cash: -900_000,
|
||||
support_adjusted_share_price: 30.0,
|
||||
book_value_per_share: 20.0,
|
||||
linked_transit_service_latch: true,
|
||||
recent_net_profits: [20_000, 10_000, 5_000],
|
||||
..base_company()
|
||||
};
|
||||
|
||||
let decision = evaluate_annual_finance_policy(&policy, &company);
|
||||
assert_eq!(
|
||||
decision,
|
||||
AnnualFinanceDecision::IssueBond {
|
||||
count: 4,
|
||||
principal_per_bond: CompanyFinanceState::BOND_PRINCIPAL,
|
||||
term_years: CompanyFinanceState::BOND_TERM_YEARS,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_issue_checks_liquidity_before_valuation() {
|
||||
let policy = AnnualFinancePolicy {
|
||||
bond_issuance_allowed: false,
|
||||
dividends_allowed: false,
|
||||
..base_policy()
|
||||
};
|
||||
let company = CompanyFinanceState {
|
||||
current_cash: 500_000,
|
||||
support_adjusted_share_price: 30.0,
|
||||
book_value_per_share: 20.0,
|
||||
recent_net_profits: [40_000, 30_000, 20_000],
|
||||
recent_revenue_totals: [250_000, 240_000, 230_000],
|
||||
..base_company()
|
||||
};
|
||||
|
||||
let decision = evaluate_annual_finance_policy(&policy, &company);
|
||||
assert_eq!(decision, AnnualFinanceDecision::NoAction);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_issue_emits_two_tranches_when_gates_pass() {
|
||||
let policy = AnnualFinancePolicy {
|
||||
dividends_allowed: false,
|
||||
..base_policy()
|
||||
};
|
||||
let company = CompanyFinanceState {
|
||||
current_cash: 100_000,
|
||||
support_adjusted_share_price: 27.5,
|
||||
book_value_per_share: 20.0,
|
||||
outstanding_share_count: 60_000,
|
||||
recent_net_profits: [40_000, 30_000, 20_000],
|
||||
recent_revenue_totals: [250_000, 240_000, 230_000],
|
||||
bonds: vec![
|
||||
BondPosition {
|
||||
principal: 150_000,
|
||||
coupon_rate: 0.12,
|
||||
years_remaining: 12,
|
||||
},
|
||||
BondPosition {
|
||||
principal: 10_000,
|
||||
coupon_rate: 0.10,
|
||||
years_remaining: 10,
|
||||
},
|
||||
],
|
||||
..base_company()
|
||||
};
|
||||
|
||||
let decision = evaluate_annual_finance_policy(&policy, &company);
|
||||
assert_eq!(
|
||||
decision,
|
||||
AnnualFinanceDecision::IssuePublicShares {
|
||||
share_count_per_tranche: 2_000,
|
||||
tranche_count: 2,
|
||||
price_per_share: 27.5,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_metric_reader_exposes_report_lanes() {
|
||||
let company = CompanyFinanceState {
|
||||
current_fuel_cost: 12_345,
|
||||
recent_net_profits: [11_000, 22_000, 33_000],
|
||||
recent_revenue_totals: [101_000, 102_000, 103_000],
|
||||
recent_revenue_per_share: [1.6, 1.5, 1.4],
|
||||
recent_earnings_per_share: [1.3, 1.2, 1.1],
|
||||
recent_dividend_per_share: [0.4, 0.3, 0.2],
|
||||
book_value_per_share: 19.5,
|
||||
..base_company()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
company.read_recent_metric(AnnualReportMetric::NetProfits, 1),
|
||||
Some(22_000.0)
|
||||
);
|
||||
assert_eq!(
|
||||
company.read_recent_metric(AnnualReportMetric::RevenuePerShare, 2),
|
||||
Some(1.4)
|
||||
);
|
||||
assert_eq!(
|
||||
company.read_recent_metric(AnnualReportMetric::FuelCost, 0),
|
||||
Some(12_345.0)
|
||||
);
|
||||
assert_eq!(
|
||||
company.read_recent_metric(AnnualReportMetric::BookValuePerShare, 0),
|
||||
Some(19.5)
|
||||
);
|
||||
assert_eq!(
|
||||
company.weighted_recent_metric(AnnualReportMetric::EarningsPerShare, &[3.0, 2.0, 1.0]),
|
||||
Some(1.2333333333333334)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debt_news_classifies_borrow_and_paydown_paths() {
|
||||
assert_eq!(
|
||||
DebtRestructureSummary {
|
||||
retired_principal: 500_000,
|
||||
issued_principal: 500_000,
|
||||
}
|
||||
.classify(),
|
||||
Some(DebtNewsOutcome::RefinanceOnly)
|
||||
);
|
||||
assert_eq!(
|
||||
DebtRestructureSummary {
|
||||
retired_principal: 300_000,
|
||||
issued_principal: 500_000,
|
||||
}
|
||||
.classify(),
|
||||
Some(DebtNewsOutcome::RefinanceAndBorrow)
|
||||
);
|
||||
assert_eq!(
|
||||
DebtRestructureSummary {
|
||||
retired_principal: 500_000,
|
||||
issued_principal: 300_000,
|
||||
}
|
||||
.classify(),
|
||||
Some(DebtNewsOutcome::RefinanceAndPayDown)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_evaluation_carries_share_and_debt_side_effects() {
|
||||
let policy = base_policy();
|
||||
let company = CompanyFinanceState {
|
||||
current_cash: -900_000,
|
||||
support_adjusted_share_price: 30.0,
|
||||
book_value_per_share: 20.0,
|
||||
linked_transit_service_latch: true,
|
||||
recent_net_profits: [20_000, 10_000, 5_000],
|
||||
..base_company()
|
||||
};
|
||||
|
||||
let evaluation = evaluate_annual_finance_policy_detailed(&policy, &company);
|
||||
assert_eq!(
|
||||
evaluation.decision,
|
||||
AnnualFinanceDecision::IssueBond {
|
||||
count: 4,
|
||||
principal_per_bond: CompanyFinanceState::BOND_PRINCIPAL,
|
||||
term_years: CompanyFinanceState::BOND_TERM_YEARS,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
evaluation.debt_news,
|
||||
Some(DebtNewsOutcome::NewBorrowingOnly)
|
||||
);
|
||||
assert_eq!(evaluation.debt_restructure.issued_principal, 2_000_000);
|
||||
assert_eq!(evaluation.repurchased_share_count, 0);
|
||||
assert_eq!(evaluation.issued_share_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_evaluation_applies_post_state_transition() {
|
||||
let snapshot = FinanceSnapshot {
|
||||
policy: AnnualFinancePolicy {
|
||||
dividends_allowed: false,
|
||||
..base_policy()
|
||||
},
|
||||
company: CompanyFinanceState {
|
||||
current_cash: 100_000,
|
||||
support_adjusted_share_price: 27.5,
|
||||
book_value_per_share: 20.0,
|
||||
outstanding_share_count: 60_000,
|
||||
recent_net_profits: [40_000, 30_000, 20_000],
|
||||
recent_revenue_totals: [250_000, 240_000, 230_000],
|
||||
bonds: vec![
|
||||
BondPosition {
|
||||
principal: 150_000,
|
||||
coupon_rate: 0.12,
|
||||
years_remaining: 12,
|
||||
},
|
||||
BondPosition {
|
||||
principal: 10_000,
|
||||
coupon_rate: 0.10,
|
||||
years_remaining: 10,
|
||||
},
|
||||
],
|
||||
..base_company()
|
||||
},
|
||||
};
|
||||
|
||||
let outcome = snapshot.evaluate();
|
||||
assert_eq!(
|
||||
outcome.evaluation.decision,
|
||||
AnnualFinanceDecision::IssuePublicShares {
|
||||
share_count_per_tranche: 2_000,
|
||||
tranche_count: 2,
|
||||
price_per_share: 27.5,
|
||||
}
|
||||
);
|
||||
assert_eq!(outcome.evaluation.issued_share_count, 4_000);
|
||||
assert_eq!(outcome.post_company.outstanding_share_count, 64_000);
|
||||
assert_eq!(outcome.post_company.unassigned_share_count, 14_000);
|
||||
assert_eq!(outcome.post_company.current_cash, 210_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dividend_target_is_quantized_and_clamped() {
|
||||
let policy = AnnualFinancePolicy {
|
||||
bond_issuance_allowed: false,
|
||||
..base_policy()
|
||||
};
|
||||
let company = CompanyFinanceState {
|
||||
current_cash: 20_000,
|
||||
board_dividend_ceiling: 0.9,
|
||||
current_dividend_per_share: 0.2,
|
||||
unassigned_share_count: 500,
|
||||
outstanding_share_count: 10_000,
|
||||
recent_earnings_per_share: [1.4, 1.1, 0.9],
|
||||
..base_company()
|
||||
};
|
||||
|
||||
let decision = evaluate_annual_finance_policy(&policy, &company);
|
||||
assert_eq!(
|
||||
decision,
|
||||
AnnualFinanceDecision::AdjustDividend {
|
||||
old_rate: 0.2,
|
||||
new_rate: 0.9,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bankruptcy_mutator_halves_bond_principal() {
|
||||
let mut company = base_company();
|
||||
company.current_company_value = 900_000;
|
||||
company.years_since_last_bankruptcy = 25;
|
||||
|
||||
company.apply_annual_decision(&AnnualFinanceDecision::DeclareBankruptcy {
|
||||
reason: BankruptcyReason::DeepDistress,
|
||||
});
|
||||
|
||||
assert_eq!(company.bonds[0].principal, 200_000);
|
||||
assert_eq!(company.bonds[1].principal, 150_000);
|
||||
assert_eq!(company.current_company_value, 450_000);
|
||||
assert_eq!(company.years_since_last_bankruptcy, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
pub mod finance;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::fs::File;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue