Improve jq microcap execution semantics

This commit is contained in:
boris
2026-04-18 18:02:50 +08:00
parent 9f4165e689
commit 0e2c25e4c4
26 changed files with 5058 additions and 362 deletions

View File

@@ -1,6 +1,5 @@
use std::collections::BTreeMap;
use chrono::NaiveDate;
use indexmap::IndexMap;
use serde::Serialize;
use crate::data::{DataSet, DataSetError, PriceField};
@@ -124,19 +123,71 @@ impl Position {
self.average_cost = total_cost / self.quantity as f64;
}
pub fn apply_cash_dividend(&mut self, dividend_per_share: f64) -> f64 {
if self.quantity == 0 || !dividend_per_share.is_finite() || dividend_per_share == 0.0 {
return 0.0;
}
for lot in &mut self.lots {
lot.price -= dividend_per_share;
}
self.average_cost -= dividend_per_share;
self.last_price -= dividend_per_share;
self.quantity as f64 * dividend_per_share
}
pub fn apply_split_ratio(&mut self, ratio: f64) -> i32 {
if self.quantity == 0 || !ratio.is_finite() || ratio <= 0.0 || (ratio - 1.0).abs() < 1e-9
{
return 0;
}
let old_quantity = self.quantity;
let mut scaled_lots = self
.lots
.iter()
.map(|lot| PositionLot {
acquired_date: lot.acquired_date,
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
price: lot.price / ratio,
})
.collect::<Vec<_>>();
let expected_total = round_half_up_u32(old_quantity as f64 * ratio);
let scaled_total = scaled_lots.iter().map(|lot| lot.quantity).sum::<u32>();
if let Some(last_lot) = scaled_lots.last_mut() {
if scaled_total < expected_total {
last_lot.quantity += expected_total - scaled_total;
} else if scaled_total > expected_total {
last_lot.quantity = last_lot
.quantity
.saturating_sub(scaled_total - expected_total);
}
}
scaled_lots.retain(|lot| lot.quantity > 0);
self.lots = scaled_lots;
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
self.last_price /= ratio;
self.recalculate_average_cost();
self.quantity as i32 - old_quantity as i32
}
}
#[derive(Debug, Clone)]
pub struct PortfolioState {
cash: f64,
positions: BTreeMap<String, Position>,
positions: IndexMap<String, Position>,
cash_receivables: Vec<CashReceivable>,
}
impl PortfolioState {
pub fn new(initial_cash: f64) -> Self {
Self {
cash: initial_cash,
positions: BTreeMap::new(),
positions: IndexMap::new(),
cash_receivables: Vec::new(),
}
}
@@ -144,7 +195,7 @@ impl PortfolioState {
self.cash
}
pub fn positions(&self) -> &BTreeMap<String, Position> {
pub fn positions(&self) -> &IndexMap<String, Position> {
&self.positions
}
@@ -152,6 +203,10 @@ impl PortfolioState {
self.positions.get(symbol)
}
pub fn position_mut_if_exists(&mut self, symbol: &str) -> Option<&mut Position> {
self.positions.get_mut(symbol)
}
pub fn position_mut(&mut self, symbol: &str) -> &mut Position {
self.positions
.entry(symbol.to_string())
@@ -166,6 +221,29 @@ impl PortfolioState {
self.positions.retain(|_, position| !position.is_flat());
}
pub fn add_cash_receivable(&mut self, receivable: CashReceivable) {
self.cash_receivables.push(receivable);
}
pub fn settle_cash_receivables(&mut self, date: NaiveDate) -> Vec<CashReceivable> {
let mut settled = Vec::new();
let mut pending = Vec::new();
for receivable in self.cash_receivables.drain(..) {
if receivable.payable_date <= date {
self.cash += receivable.amount;
settled.push(receivable);
} else {
pending.push(receivable);
}
}
self.cash_receivables = pending;
settled
}
pub fn cash_receivables(&self) -> &[CashReceivable] {
&self.cash_receivables
}
pub fn update_prices(
&mut self,
date: NaiveDate,
@@ -173,16 +251,17 @@ impl PortfolioState {
field: PriceField,
) -> Result<(), DataSetError> {
for position in self.positions.values_mut() {
let price = data
.price(date, &position.symbol, field)
.ok_or_else(|| DataSetError::MissingSnapshot {
let price = data.price(date, &position.symbol, field).ok_or_else(|| {
DataSetError::MissingSnapshot {
kind: match field {
PriceField::Open => "open price",
PriceField::Close => "close price",
PriceField::Last => "last price",
},
date,
symbol: position.symbol.clone(),
})?;
}
})?;
position.last_price = price;
}
Ok(())
@@ -214,6 +293,30 @@ impl PortfolioState {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn positions_preserve_insertion_order() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut portfolio = PortfolioState::new(10_000.0);
portfolio.position_mut("603657.SH").buy(date, 100, 10.0);
portfolio.position_mut("001266.SZ").buy(date, 100, 10.0);
portfolio.position_mut("601798.SH").buy(date, 100, 10.0);
let symbols = portfolio.positions().keys().cloned().collect::<Vec<_>>();
assert_eq!(
symbols,
vec![
"603657.SH".to_string(),
"001266.SZ".to_string(),
"601798.SH".to_string()
]
);
}
}
#[derive(Debug, Clone, Serialize)]
pub struct HoldingSummary {
#[serde(with = "date_format")]
@@ -227,6 +330,15 @@ pub struct HoldingSummary {
pub realized_pnl: f64,
}
#[derive(Debug, Clone)]
pub struct CashReceivable {
pub symbol: String,
pub ex_date: NaiveDate,
pub payable_date: NaiveDate,
pub amount: f64,
pub reason: String,
}
mod date_format {
use chrono::NaiveDate;
use serde::Serializer;
@@ -240,3 +352,11 @@ mod date_format {
serializer.serialize_str(&date.format(FORMAT).to_string())
}
}
fn round_half_up_u32(value: f64) -> u32 {
if !value.is_finite() || value <= 0.0 {
0
} else {
value.round() as u32
}
}