use crate::train::{MetadataConfig, ModelConfig, log_returns, parse_prices};
use burn::{
backend::{NdArray, ndarray::NdArrayDevice},
module::Module,
prelude::*,
record::CompactRecorder,
};
use clap::Args as ClapArgs;
use std::{error::Error, fs, io, path::PathBuf};
#[derive(ClapArgs)]
pub struct Args {
#[arg(long, default_value = "model")]
model_directory: PathBuf,
#[arg(long, default_value = "data/inference-sample.csv")]
input_path: PathBuf,
}
pub fn run(args: &Args) -> Result<(), Box<dyn Error>> {
let config = ModelConfig::load(args.model_directory.join("model.json"))?;
let metadata = MetadataConfig::load(args.model_directory.join("metadata.json"))?;
if !metadata.return_mean.is_finite()
|| !metadata.return_deviation.is_finite()
|| metadata.return_deviation <= f32::EPSILON
{
return Err("model metadata contains invalid normalization values".into());
}
let contents = fs::read_to_string(&args.input_path)
.map_err(|error| format!("failed to read {}: {error}", args.input_path.display()))?;
let prices = parse_prices(&contents)
.map_err(|error| format!("failed to parse {}: {error}", args.input_path.display()))?;
let expected_prices = config.inputs + 1;
if prices.len() != expected_prices {
return Err(format!(
concat!(
"inference input must contain exactly {} prices to produce {} returns, ",
"but found {}",
),
expected_prices,
config.inputs,
prices.len(),
)
.into());
}
let normalized = log_returns(&prices)
.into_iter()
.map(|value| (value - metadata.return_mean) / metadata.return_deviation)
.collect::<Vec<_>>();
let device = NdArrayDevice::Cpu;
let model = config.init::<NdArray>(&device).load_file(
args.model_directory.join("model"),
&CompactRecorder::new(),
&device,
)?;
let inputs = Tensor::<NdArray, 1>::from_floats(normalized.as_slice(), &device).unsqueeze();
let normalized_predictions = model.forward(inputs).into_data().to_vec::<f32>()?;
let predicted_returns = normalized_predictions
.into_iter()
.map(|value| value * metadata.return_deviation + metadata.return_mean);
let predicted_prices = forecast_prices(*prices.last().unwrap(), predicted_returns);
write_predictions(io::stdout().lock(), &predicted_prices)?;
Ok(())
}
fn forecast_prices(
initial_price: f32,
predicted_returns: impl IntoIterator<Item = f32>,
) -> Vec<f32> {
let mut price = initial_price;
predicted_returns
.into_iter()
.map(|predicted_return| {
price *= predicted_return.exp();
price
})
.collect()
}
fn write_predictions(writer: impl io::Write, prices: &[f32]) -> Result<(), Box<dyn Error>> {
let mut writer = csv::Writer::from_writer(writer);
writer.write_record(["open"])?;
for price in prices {
writer.write_record([price.to_string()])?;
}
writer.flush()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{forecast_prices, write_predictions};
use crate::{Cli, Subcommand};
use clap::Parser;
use std::path::PathBuf;
#[test]
fn parse_infer_subcommand() {
let cli = Cli::try_parse_from(["stockholm", "infer"]).unwrap();
let Some(Subcommand::Infer(args)) = cli.command else {
panic!("expected infer subcommand");
};
assert_eq!(args.model_directory, PathBuf::from("model"));
assert_eq!(args.input_path, PathBuf::from("data/inference-sample.csv"));
}
#[test]
fn reconstruct_and_write_predictions() {
let prices = forecast_prices(100.0, [0.0, 2.0_f32.ln()]);
let mut output = Vec::new();
write_predictions(&mut output, &prices).unwrap();
assert_eq!(String::from_utf8(output).unwrap(), "open\n100\n200\n");
}
}