use std::sync::Arc;
use polars::{
prelude::{
DataFrame, DataType, Expr, Field, PlSmallStr, Schema, SchemaRef, SortMultipleOptions,
TimeUnit, TimeZone, lit,
},
series::IsSorted,
};
use serde::{Deserialize, Serialize};
use strum::{Display, EnumIter, EnumString, IntoEnumIterator, IntoStaticStr};
use crate::{
data::common::RiskMetricsConfig,
error::{ChapatyError, ChapatyResult, DataError},
gym::trading::StateKind,
report::{
cumulative_returns::CumulativeReturns,
grouped::{GroupCol, GroupedJournal},
io::{Report, ReportName, ToSchema, generate_dynamic_base_name},
portfolio_performance::PortfolioPerformance,
trade_statistics::TradeStatistics,
},
};
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
EnumString,
Display,
PartialOrd,
Ord,
EnumIter,
IntoStaticStr,
)]
#[strum(serialize_all = "snake_case")]
pub enum JournalCol {
RowId,
EpisodeId,
TradeId,
TradeState,
AgentId,
DataBroker,
Exchange,
Symbol,
MarketType,
TradeType,
EntryPrice,
StopLossPrice,
TakeProfitPrice,
Quantity,
ExpectedLossInTicks,
ExpectedProfitInTicks,
ExpectedLossDollars,
ExpectedProfitDollars,
RiskRewardRatio,
EntryTimestamp,
ExitTimestamp,
ExitPrice,
ExitReason,
RealizedReturnInTicks,
RealizedReturnDollars,
}
impl TryFrom<JournalCol> for GroupCol {
type Error = ChapatyError;
fn try_from(value: JournalCol) -> Result<Self, Self::Error> {
match value {
JournalCol::EpisodeId => Ok(Self::EpisodeId),
JournalCol::TradeState => Ok(Self::TradeState),
JournalCol::AgentId => Ok(Self::AgentId),
JournalCol::DataBroker => Ok(Self::DataBroker),
JournalCol::Exchange => Ok(Self::Exchange),
JournalCol::Symbol => Ok(Self::Symbol),
JournalCol::MarketType => Ok(Self::MarketType),
JournalCol::TradeType => Ok(Self::TradeType),
JournalCol::EntryTimestamp => {
Err(DataError::UnexpectedEnumVariant(
"Cannot convert JournalCol::EntryTimestamp to GroupCol: ambiguous mapping (could be EntryYear, EntryQuarter, or EntryMonth)".to_string()
).into())
}
JournalCol::ExitTimestamp => {
Err(DataError::UnexpectedEnumVariant(
"Cannot convert JournalCol::ExitTimestamp to GroupCol: ambiguous mapping (could be ExitYear, ExitQuarter, or ExitMonth)".to_string()
).into())
}
JournalCol::ExitReason => Ok(Self::ExitReason),
JournalCol::RowId
| JournalCol::TradeId
| JournalCol::EntryPrice
| JournalCol::StopLossPrice
| JournalCol::TakeProfitPrice
| JournalCol::Quantity
| JournalCol::ExpectedLossInTicks
| JournalCol::ExpectedProfitInTicks
| JournalCol::ExpectedLossDollars
| JournalCol::ExpectedProfitDollars
| JournalCol::RiskRewardRatio
| JournalCol::ExitPrice
| JournalCol::RealizedReturnInTicks
| JournalCol::RealizedReturnDollars => Err(DataError::UnexpectedEnumVariant(
format!("JournalCol variant '{value}' has no corresponding GroupCol mapping")
)
.into()),
}
}
}
impl From<JournalCol> for PlSmallStr {
fn from(value: JournalCol) -> Self {
value.as_str().into()
}
}
impl JournalCol {
#[must_use]
pub fn name(&self) -> PlSmallStr {
(*self).into()
}
#[must_use]
pub fn as_str(&self) -> &'static str {
self.into()
}
}
#[derive(Debug, Clone)]
pub struct Journal {
df: DataFrame,
risk_metrics_config: RiskMetricsConfig,
}
impl ReportName for Journal {
fn base_name(&self) -> String {
generate_dynamic_base_name(&self.df, "journal")
}
}
impl Report for Journal {
fn as_df(&self) -> &DataFrame {
&self.df
}
fn as_df_mut(&mut self) -> &mut DataFrame {
&mut self.df
}
}
impl Journal {
pub fn cumulative_returns(&self) -> ChapatyResult<CumulativeReturns> {
self.try_into()
}
pub fn portfolio_performance(&self) -> ChapatyResult<PortfolioPerformance> {
self.try_into()
}
pub fn trade_stats(&self) -> ChapatyResult<TradeStatistics> {
self.try_into()
}
pub const fn risk_metrics_config(&self) -> RiskMetricsConfig {
self.risk_metrics_config
}
pub fn group_by<I>(&self, keys: I) -> GroupedJournal<'_>
where
I: IntoIterator<Item = GroupCol>,
{
GroupedJournal::new(self, keys)
}
}
impl Journal {
pub(crate) fn new(df: &DataFrame, config: RiskMetricsConfig) -> ChapatyResult<Self> {
let sorted_df = df
.sort(
[JournalCol::EntryTimestamp.as_str()],
SortMultipleOptions::default(),
)
.map_err(|e| ChapatyError::Data(DataError::DataFrame(e.to_string())))?;
sorted_df
.column(JournalCol::EntryTimestamp.as_str())
.ok()
.map(|s| s.is_sorted_flag() == IsSorted::Ascending)
.ok_or_else(|| {
ChapatyError::Data(DataError::DataFrame(
"Journal must be sorted by entry timestamp".to_string(),
))
})?;
Ok(Self {
df: sorted_df,
risk_metrics_config: config,
})
}
}
impl Default for Journal {
fn default() -> Self {
let df = DataFrame::empty_with_schema(&Self::to_schema());
let config = RiskMetricsConfig::default();
Self {
df,
risk_metrics_config: config,
}
}
}
impl ToSchema for Journal {
fn to_schema() -> SchemaRef {
let fields = JournalCol::iter()
.map(|col| {
let dtype = match col {
JournalCol::RowId | JournalCol::EpisodeId => DataType::UInt32,
JournalCol::TradeState
| JournalCol::AgentId
| JournalCol::DataBroker
| JournalCol::Exchange
| JournalCol::Symbol
| JournalCol::MarketType
| JournalCol::TradeType
| JournalCol::ExitReason => DataType::String,
JournalCol::Quantity
| JournalCol::EntryPrice
| JournalCol::StopLossPrice
| JournalCol::TakeProfitPrice
| JournalCol::ExpectedLossDollars
| JournalCol::ExpectedProfitDollars
| JournalCol::RiskRewardRatio
| JournalCol::ExitPrice
| JournalCol::RealizedReturnDollars => DataType::Float64,
JournalCol::TradeId
| JournalCol::ExpectedLossInTicks
| JournalCol::ExpectedProfitInTicks
| JournalCol::RealizedReturnInTicks => DataType::Int64,
JournalCol::EntryTimestamp | JournalCol::ExitTimestamp => {
DataType::Datetime(TimeUnit::Microseconds, Some(TimeZone::UTC))
}
};
Field::new(col.into(), dtype)
})
.collect::<Vec<_>>();
Arc::new(Schema::from_iter(fields))
}
}
pub trait ExprDefineExt {
fn define_as<C: Into<PlSmallStr>>(self, col: C, dtype: DataType) -> Expr;
}
impl ExprDefineExt for Expr {
fn define_as<C: Into<PlSmallStr>>(self, col: C, dtype: DataType) -> Expr {
self.cast(dtype).alias(col)
}
}
pub trait JournalExprExt {
fn trade_executed(self) -> Expr;
fn count_true(self) -> Expr;
}
impl JournalExprExt for Expr {
fn trade_executed(self) -> Expr {
self.clone()
.eq(lit(StateKind::Active.as_str()))
.or(self.eq(lit(StateKind::Closed.as_str())))
}
fn count_true(self) -> Expr {
self.cast(DataType::UInt32).sum()
}
}
#[cfg(test)]
mod test {
#![expect(
clippy::unwrap_used,
clippy::expect_used,
reason = "tests assert against known-valid fixtures; unwrap and expect surface failures as panics that fail the test"
)]
use std::path::PathBuf;
use polars::prelude::{IntoLazy, LazyCsvReader, LazyFileListReader, PlRefPath, col};
use super::*;
fn load_journal_fixture() -> Journal {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let fixture_path =
PathBuf::from(manifest_dir).join("tests/fixtures/report/input/journal.csv");
assert!(
fixture_path.exists(),
"Test fixture missing: {}",
fixture_path.display()
);
let schema = Journal::to_schema();
let df = LazyCsvReader::new(PlRefPath::new(
fixture_path
.to_str()
.expect("Invalid UTF-8 in fixture path"),
))
.with_has_header(true)
.with_schema(Some(schema))
.with_try_parse_dates(true)
.finish()
.expect("Failed to create LazyFrame")
.collect()
.expect("Failed to collect DataFrame");
Journal::new(&df, RiskMetricsConfig::default()).expect("Failed to create Journal")
}
#[test]
fn test_journal_creation_and_schema_validation() {
let journal = load_journal_fixture();
let df = &journal.as_df();
let current_schema = df.schema();
let expected_schema = Journal::to_schema();
for (name, expected_dtype) in expected_schema.iter() {
let actual_dtype = current_schema.get(name);
assert!(
actual_dtype.is_some(),
"Missing column in Journal DataFrame: {name}"
);
assert_eq!(
actual_dtype.unwrap(),
expected_dtype,
"Type mismatch for column '{}'. Expected {:?}, got {:?}",
name,
expected_dtype,
actual_dtype.unwrap()
);
}
}
#[test]
fn test_is_executed_expr_filters_correctly() {
let journal = load_journal_fixture();
let filtered_df = journal
.as_df()
.clone()
.lazy()
.filter(col(JournalCol::TradeState).trade_executed())
.collect()
.expect("Failed to apply is_executed_expr filter");
assert_eq!(
filtered_df.height(),
6,
"is_executed_expr should retain exactly 6 rows (Active/Closed) from the fixture, dropping Pending/Canceled."
);
let states = filtered_df
.column(JournalCol::TradeState.as_str())
.expect("Missing TradeState column")
.str()
.expect("TradeState column is not of type String");
for state_opt in states.iter() {
let state = state_opt.expect("Encountered null state");
assert!(
state == StateKind::Active.as_str() || state == StateKind::Closed.as_str(),
"Found unexecuted state in filtered results: {state}"
);
}
}
}