shindo_coding_utils 0.4.3

A utils crates which will be used in various micro-services
Documentation
use reqwest::{Client, header};
use serde::Serialize;
use std::fmt;
use std::{sync::Arc, time::Duration as StdDuration};

use crate::schemas::historical_data;
use crate::types;

const SYSTEM_PROMPT: &'static str = r#"You are a meticulous quantitative financial analyst. Your sole purpose is to perform a rigorous, data-driven technical analysis of a given stock's historical data and produce a structured forecast. You must base all conclusions strictly on calculated technical indicators and observed patterns from the provided CSV data. You must remain completely objective and give equal weight to both bullish and bearish signals.

**Core Mandate: Your reasoning must be transparent and quantitative. For every claim you make, you must cite the specific data or indicator that supports it.**

**Input Data:**
You will receive the raw text content of a CSV file with the columns: `date`, `symbol`, `price_high`, `price_low`, `price_open`, `price_close`, `deal_volume`, `price_average`.

**Analysis & Prediction Protocol:**

1.  **Indicator Calculation (Internal Step):** Before any analysis, you MUST internally calculate the following standard technical indicators for the entire dataset:
    *   Simple Moving Averages: SMA(20) and SMA(50).
    *   Relative Strength Index: RSI(14).
    *   MACD: (12-period EMA - 26-period EMA) and its 9-period EMA signal line.

2.  **Current Market State Analysis:**
    *   Focus on the **last 3 trading days**.
    *   State the exact calculated values for SMA(20), SMA(50), RSI(14), and the MACD line/signal line for the most recent day.
    *   Describe the most recent candlestick pattern and its implication.
    *   Analyze recent volume and its implication for the strength of the recent price movement.

3.  **Scenario Analysis (Crucial Step):** You MUST construct two competing hypotheses for the next trading day.
    *   **Bullish Case (Price Increase):** List every piece of technical evidence from your analysis that supports a price increase. This includes indicator readings (e.g., RSI not yet overbought, MACD crossover), chart patterns, and support levels holding.
    *   **Bearish Case (Price Decrease):** List every piece of technical evidence that supports a price decrease. This includes bearish candlestick patterns (e.g., shooting star), indicators showing weakness (e.g., bearish divergence on RSI, price hitting resistance), or high volume on a down day.

4.  **Synthesis and Final Prediction:**
    *   **Conclusion:** After evaluating both the Bullish and Bearish cases, you must explicitly state which case has stronger evidence and is therefore more probable.
    *   **Next Day Prediction:** Based on your conclusion, provide the specific predictions for `price_high`, `price_low`, and `deal_volume`.
    *   **Justification:** Your justification must explain *why* you chose one scenario over the other, referencing the evidence you listed. For example: "Although the long-term trend is up, the Bearish Case is stronger for the next day due to the formation of a bearish engulfing candle at a key resistance level, combined with a downturn in the RSI."
    *   **Invalidating Signal:** State the key price level or indicator signal that would immediately invalidate your chosen scenario.


**Output Format:**
Your entire response MUST be a single, valid JSON object conforming to the schema below. No explanatory text outside the JSON.

```json
{
  "analysis_date": "The date the analysis is being performed.",
  "symbol": "The stock symbol being analyzed.",
  "current_state_analysis": {
    "last_date_in_dataset": "The date of the last row of data.",
    "recent_indicators": {
      "sma20": "Calculated SMA(20) value for the last day.",
      "sma50": "Calculated SMA(50) value for the last day.",
      "rsi14": "Calculated RSI(14) value for the last day.",
      "macd": "Calculated MACD line value for the last day.",
      "macd_signal": "Calculated MACD signal line value for the last day."
    },
    "recent_candlestick": "Description of the last 1-3 candlestick patterns and their implication.",
    "recent_volume": "Analysis of volume over the last 3 days and its implication."
  },
  "scenario_analysis": {
    "bullish_case": {
      "summary": "A brief summary of the bullish argument.",
      "supporting_evidence": [
        "List of specific, data-backed bullet points supporting a price increase."
      ]
    },
    "bearish_case": {
      "summary": "A brief summary of the bearish argument.",
      "supporting_evidence": [
        "List of specific, data-backed bullet points supporting a price decrease."
      ]
    }
  },
  "next_day_prediction": {
    "predicted_date": "The date for which the prediction is made (the day after the last entry in the data).",
    "conclusion": "A clear statement on whether the Bullish or Bearish case is more probable.",
    "predicted_price_high": "A numerical value for the predicted high price.",
    "predicted_price_low": "A numerical value for the predicted low price.",
    "predicted_volume": "A numerical value for the predicted deal volume.",
    "confidence": "Either 'Low', 'Medium', or 'High'.",
    "justification": "A concluding statement explaining why the chosen scenario is more probable, referencing the evidence from the scenario analysis.",
    "invalidating_signal": "The key signal that would invalidate the prediction (e.g., 'A close above 32500 would invalidate the bearish prediction.')."
  },
  "long_term_outlook": {
    "timeframe": "3 Months",
    "expected_behavior": "Bullish, Bearish, or Range-Bound.",
    "predicted_price_range": {
      "min": "The minimum expected price in the next 3 months.",
      "max": "The maximum expected price in the next 3 months."
    },
    "justification": "High-level reasoning for the long-term outlook."
  }
}
"#;

pub struct AiService {
    base_url: String,
    api_token: String,
    client: Arc<Client>,
}

#[derive(Serialize)]
struct RequestBody<'a> {
    stream: bool,
    model: &'a str,
    messages: Vec<Message<'a>>,
    params: Params,
    model_item: ModelItem<'a>,
    background_tasks: BackgroundTasks,
    stream_options: StreamOptions,
}

#[derive(Serialize)]
struct Message<'a> {
    role: &'a str,
    content: String,
}

#[derive(Serialize)]
struct Params {
    temperature: u8,
    reasoning_effort: &'static str,
}

#[derive(Serialize)]
struct ModelItem<'a> {
    id: &'a str,
    name: &'a str,
    owned_by: &'a str,
    openai: OpenAIInfo<'a>,
    #[serde(rename = "urlIdx")]
    url_idx: u8,
    connection_type: &'a str,
    actions: Vec<()>,
    filters: Vec<()>,
    tags: Vec<()>,
}

#[derive(Serialize)]
struct OpenAIInfo<'a> {
    id: &'a str,
    name: &'a str,
    owned_by: &'a str,
    openai: OpenAIInnerInfo<'a>,
    #[serde(rename = "urlIdx")]
    url_idx: u8,
    connection_type: &'a str,
}

#[derive(Serialize)]
struct OpenAIInnerInfo<'a> {
    id: &'a str,
}

#[derive(Serialize)]
struct BackgroundTasks {
    follow_up_generation: bool,
}

#[derive(Serialize)]
struct StreamOptions {
    include_usage: bool,
}

#[derive(Debug)]
pub enum FetchPredictionError {
    Request(reqwest::Error),
    Parse(serde_json::Error),
    NoChoices,
}

// Implement the Display trait for user-friendly error messages
impl fmt::Display for FetchPredictionError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            FetchPredictionError::Request(e) => write!(f, "Request error: {}", e),
            FetchPredictionError::Parse(e) => write!(f, "JSON parsing error: {}", e),
            FetchPredictionError::NoChoices => write!(f, "API response contained no choices"),
        }
    }
}

// Implement the Error trait to be a proper error type
impl std::error::Error for FetchPredictionError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            FetchPredictionError::Request(e) => Some(e),
            FetchPredictionError::Parse(e) => Some(e),
            FetchPredictionError::NoChoices => None,
        }
    }
}

// Implement From traits for automatic conversion using the `?` operator
impl From<reqwest::Error> for FetchPredictionError {
    fn from(err: reqwest::Error) -> FetchPredictionError {
        FetchPredictionError::Request(err)
    }
}

impl From<serde_json::Error> for FetchPredictionError {
    fn from(err: serde_json::Error) -> FetchPredictionError {
        FetchPredictionError::Parse(err)
    }
}

impl AiService {
    pub fn new() -> Self {
        let api_token = std::env::var("OPEN_WEBUI_API_TOKEN")
            .expect("OPEN_WEBUI_API_TOKEN environment variable not set");

        // Create a reqwest client with default configuration
        let client = Client::builder()
            .timeout(StdDuration::from_secs(30))
            .build()
            .expect("Failed to create HTTP client");

        Self {
            base_url: "https://chat.go1.co".to_string(),
            api_token,
            client: Arc::new(client),
        }
    }

    pub async fn fetch_prediction_by_ticker(
        &self,
        historical_data: Vec<historical_data::Model>,
    ) -> Result<types::StockAnalysis, FetchPredictionError> {
        let url = format!("{}/api/chat/completions", self.base_url);
        let client = self.client.clone();

        // Convert historical data to a string (e.g., CSV format)
        let user_content = historical_data
            .iter()
            .map(|d| {
                format!(
                    "{},{},{},{},{},{},{},{}",
                    d.date,
                    d.ticker,
                    d.price_high,
                    d.price_low,
                    d.price_open,
                    d.price_close,
                    d.deal_volume,
                    d.price_average
                )
            })
            .collect::<Vec<String>>()
            .join("\n");

        // Construct the request body
        let request_body = RequestBody {
            stream: false,
            model: "gpt-4.1",
            messages: vec![
                Message {
                    role: "system",
                    content: SYSTEM_PROMPT.to_string(),
                },
                Message {
                    role: "user",
                    content: user_content,
                },
            ],
            params: Params {
                temperature: 0,
                reasoning_effort: "medium",
            },
            model_item: ModelItem {
                id: "gpt-4.1",
                name: "Azure: gpt-4.1",
                owned_by: "openai",
                openai: OpenAIInfo {
                    id: "gpt-4.1",
                    name: "gpt-4.1",
                    owned_by: "openai",
                    openai: OpenAIInnerInfo { id: "gpt-4.1" },
                    url_idx: 2,
                    connection_type: "external",
                },
                url_idx: 2,
                connection_type: "external",
                actions: vec![],
                filters: vec![],
                tags: vec![],
            },
            background_tasks: BackgroundTasks {
                follow_up_generation: false,
            },
            stream_options: StreamOptions {
                include_usage: true,
            },
        };

        let response = client
            .post(&url)
            .header(header::AUTHORIZATION, format!("Bearer {}", &self.api_token))
            .json(&request_body) // This serializes the body and sets the Content-Type header
            .send()
            .await?;

        let result: types::StockAnalysisResponse = response.json().await?;

        let first_choice = result
            .choices
            .first()
            .ok_or(FetchPredictionError::NoChoices)?;
        let json_string = first_choice.message.content.clone();

        // The `?` operator will now convert a `serde_json::Error` into our custom error type.
        let analysis = parse_json_response(json_string)?;
        Ok(analysis)
    }
}

fn parse_json_response(string: String) -> Result<types::StockAnalysis, serde_json::Error> {
    let json = string
        .trim_start_matches("```json\n")
        .trim_end_matches("\n```")
        .trim();

    serde_json::from_str(json)
}