初始化回测核心引擎骨架
This commit is contained in:
242
crates/fidc-core/src/portfolio.rs
Normal file
242
crates/fidc-core/src/portfolio.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::data::{DataSet, DataSetError, PriceField};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PositionLot {
|
||||
pub acquired_date: NaiveDate,
|
||||
pub quantity: u32,
|
||||
pub price: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Position {
|
||||
pub symbol: String,
|
||||
pub quantity: u32,
|
||||
pub average_cost: f64,
|
||||
pub last_price: f64,
|
||||
pub realized_pnl: f64,
|
||||
lots: Vec<PositionLot>,
|
||||
}
|
||||
|
||||
impl Position {
|
||||
pub fn new(symbol: impl Into<String>) -> Self {
|
||||
Self {
|
||||
symbol: symbol.into(),
|
||||
quantity: 0,
|
||||
average_cost: 0.0,
|
||||
last_price: 0.0,
|
||||
realized_pnl: 0.0,
|
||||
lots: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_flat(&self) -> bool {
|
||||
self.quantity == 0
|
||||
}
|
||||
|
||||
pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) {
|
||||
if quantity == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
self.lots.push(PositionLot {
|
||||
acquired_date: date,
|
||||
quantity,
|
||||
price,
|
||||
});
|
||||
self.quantity += quantity;
|
||||
self.last_price = price;
|
||||
self.recalculate_average_cost();
|
||||
}
|
||||
|
||||
pub fn sell(&mut self, quantity: u32, price: f64) -> Result<f64, String> {
|
||||
if quantity > self.quantity {
|
||||
return Err(format!(
|
||||
"sell quantity {} exceeds current quantity {} for {}",
|
||||
quantity, self.quantity, self.symbol
|
||||
));
|
||||
}
|
||||
|
||||
let mut remaining = quantity;
|
||||
let mut realized = 0.0;
|
||||
|
||||
while remaining > 0 {
|
||||
let Some(first_lot) = self.lots.first_mut() else {
|
||||
return Err(format!("position {} has no lots to sell", self.symbol));
|
||||
};
|
||||
|
||||
let lot_sell = remaining.min(first_lot.quantity);
|
||||
realized += (price - first_lot.price) * lot_sell as f64;
|
||||
first_lot.quantity -= lot_sell;
|
||||
remaining -= lot_sell;
|
||||
|
||||
if first_lot.quantity == 0 {
|
||||
self.lots.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
self.quantity -= quantity;
|
||||
self.last_price = price;
|
||||
self.realized_pnl += realized;
|
||||
self.recalculate_average_cost();
|
||||
Ok(realized)
|
||||
}
|
||||
|
||||
pub fn sellable_qty(&self, date: NaiveDate) -> u32 {
|
||||
self.lots
|
||||
.iter()
|
||||
.filter(|lot| lot.acquired_date < date)
|
||||
.map(|lot| lot.quantity)
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub fn market_value(&self) -> f64 {
|
||||
self.quantity as f64 * self.last_price
|
||||
}
|
||||
|
||||
pub fn unrealized_pnl(&self) -> f64 {
|
||||
(self.last_price - self.average_cost) * self.quantity as f64
|
||||
}
|
||||
|
||||
pub fn holding_return(&self, price: f64) -> Option<f64> {
|
||||
if self.quantity == 0 || self.average_cost <= 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some((price / self.average_cost) - 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn recalculate_average_cost(&mut self) {
|
||||
if self.quantity == 0 {
|
||||
self.average_cost = 0.0;
|
||||
return;
|
||||
}
|
||||
|
||||
let total_cost = self
|
||||
.lots
|
||||
.iter()
|
||||
.map(|lot| lot.price * lot.quantity as f64)
|
||||
.sum::<f64>();
|
||||
|
||||
self.average_cost = total_cost / self.quantity as f64;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PortfolioState {
|
||||
cash: f64,
|
||||
positions: BTreeMap<String, Position>,
|
||||
}
|
||||
|
||||
impl PortfolioState {
|
||||
pub fn new(initial_cash: f64) -> Self {
|
||||
Self {
|
||||
cash: initial_cash,
|
||||
positions: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cash(&self) -> f64 {
|
||||
self.cash
|
||||
}
|
||||
|
||||
pub fn positions(&self) -> &BTreeMap<String, Position> {
|
||||
&self.positions
|
||||
}
|
||||
|
||||
pub fn position(&self, symbol: &str) -> Option<&Position> {
|
||||
self.positions.get(symbol)
|
||||
}
|
||||
|
||||
pub fn position_mut(&mut self, symbol: &str) -> &mut Position {
|
||||
self.positions
|
||||
.entry(symbol.to_string())
|
||||
.or_insert_with(|| Position::new(symbol))
|
||||
}
|
||||
|
||||
pub fn apply_cash_delta(&mut self, delta: f64) {
|
||||
self.cash += delta;
|
||||
}
|
||||
|
||||
pub fn prune_flat_positions(&mut self) {
|
||||
self.positions.retain(|_, position| !position.is_flat());
|
||||
}
|
||||
|
||||
pub fn update_prices(
|
||||
&mut self,
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
field: PriceField,
|
||||
) -> Result<(), DataSetError> {
|
||||
for position in self.positions.values_mut() {
|
||||
let price = data
|
||||
.price(date, &position.symbol, field)
|
||||
.ok_or_else(|| DataSetError::MissingSnapshot {
|
||||
kind: match field {
|
||||
PriceField::Open => "open price",
|
||||
PriceField::Close => "close price",
|
||||
},
|
||||
date,
|
||||
symbol: position.symbol.clone(),
|
||||
})?;
|
||||
position.last_price = price;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn market_value(&self) -> f64 {
|
||||
self.positions.values().map(Position::market_value).sum()
|
||||
}
|
||||
|
||||
pub fn total_equity(&self) -> f64 {
|
||||
self.cash + self.market_value()
|
||||
}
|
||||
|
||||
pub fn holdings_summary(&self, date: NaiveDate) -> Vec<HoldingSummary> {
|
||||
self.positions
|
||||
.values()
|
||||
.filter(|position| position.quantity > 0)
|
||||
.map(|position| HoldingSummary {
|
||||
date,
|
||||
symbol: position.symbol.clone(),
|
||||
quantity: position.quantity,
|
||||
average_cost: position.average_cost,
|
||||
last_price: position.last_price,
|
||||
market_value: position.market_value(),
|
||||
unrealized_pnl: position.unrealized_pnl(),
|
||||
realized_pnl: position.realized_pnl,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct HoldingSummary {
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
pub symbol: String,
|
||||
pub quantity: u32,
|
||||
pub average_cost: f64,
|
||||
pub last_price: f64,
|
||||
pub market_value: f64,
|
||||
pub unrealized_pnl: f64,
|
||||
pub realized_pnl: f64,
|
||||
}
|
||||
|
||||
mod date_format {
|
||||
use chrono::NaiveDate;
|
||||
use serde::Serializer;
|
||||
|
||||
const FORMAT: &str = "%Y-%m-%d";
|
||||
|
||||
pub fn serialize<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&date.format(FORMAT).to_string())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user