summaryrefslogtreecommitdiff
path: root/src/main.rs
blob: f48b35ac60999317815d38fee80b7612c72398d0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
mod data;
mod indicators;
mod resample;
mod signals;

use resample::Interval;
use serde_json::json;
use std::path::PathBuf;
use std::process::ExitCode;

const TEMPLATE: &str = include_str!("../web/template.html");
const USAGE: &str = "usage: stock <input.csv> [-o out.html] [--rsi 14] [--kd 9] [--atr 2.5 | --swing PCT] [--smooth N] [--interval day|week|month]";
// Swing detection for divergences: ZigZag with a reversal threshold of k*ATR(14) (--atr, default 2.5)
// or a fixed percentage (--swing). --smooth N locates swings on an N-bar EMA, then snaps them to real highs/lows.

struct Args {
    input: PathBuf,
    output: Option<PathBuf>,
    rsi: usize,
    kd: usize,
    atr_k: f64,
    swing: Option<f64>,
    smooth: usize,
    interval: Interval,
}

fn parse_args() -> Result<Args, String> {
    let mut it = std::env::args().skip(1);
    let (mut input, mut output, mut rsi, mut kd) = (None, None, 14, 9);
    let (mut atr_k, mut swing, mut smooth, mut interval) = (2.5, None, 0, Interval::Day);
    while let Some(a) = it.next() {
        let mut val = |name: &str| it.next().ok_or(format!("{name} needs a value"));
        match a.as_str() {
            "-o" => output = Some(PathBuf::from(val("-o")?)),
            "--rsi" => rsi = val("--rsi")?.parse().map_err(|e| format!("--rsi: {e}"))?,
            "--kd" => kd = val("--kd")?.parse().map_err(|e| format!("--kd: {e}"))?,
            "--swing" => swing = Some(val("--swing")?.parse().map_err(|e| format!("--swing: {e}"))?),
            "--atr" => atr_k = val("--atr")?.parse().map_err(|e| format!("--atr: {e}"))?,
            "--smooth" => smooth = val("--smooth")?.parse().map_err(|e| format!("--smooth: {e}"))?,
            "--interval" => {
                let v = val("--interval")?;
                interval = Interval::parse(&v).ok_or(format!("--interval: expected day, week or month, got '{v}'"))?;
            }
            "-h" | "--help" => return Err(String::new()),
            _ if a.starts_with('-') => return Err(format!("unknown option {a}")),
            _ => input = Some(PathBuf::from(a)),
        }
    }
    if rsi == 0 || kd == 0 || atr_k <= 0.0 || swing.is_some_and(|p| !(p > 0.0 && p < 100.0)) {
        return Err("periods must be positive, --atr > 0, --swing within (0, 100)".into());
    }
    Ok(Args { input: input.ok_or("missing input.csv")?, output, rsi, kd, atr_k, swing, smooth, interval })
}

/// Indicators, signals and divergences for one bar interval; also returns a stats text.
fn analyse(candles: &[data::Candle], interval: Interval, args: &Args) -> (serde_json::Value, String) {
    let rsi = indicators::rsi(candles, args.rsi);
    let (k, d) = indicators::kd(candles, args.kd);
    let thr = match args.swing {
        Some(p) => signals::Threshold::Percent(p),
        None => signals::Threshold::Atr { k: args.atr_k, atr: indicators::atr(candles, 14) },
    };
    let swings = if args.smooth > 0 {
        let found = signals::zigzag(&indicators::ema_candles(candles, args.smooth), &thr);
        signals::snap_swings(candles, &found, args.smooth)
    } else {
        signals::zigzag(candles, &thr)
    };
    let mut divs = signals::divergences(candles, &swings, &thr, &rsi, "RSI", &signals::Filters::rsi());
    divs.extend(signals::divergences(candles, &swings, &thr, &k, "K", &signals::Filters::k()));
    divs.sort_by_key(|d| d.confirmed);
    let stats = format!("[{}] {} bars\n{}", interval.name(), candles.len(), signals::forward_stats(candles, &divs, &[5, 10, 20]));
    let payload = json!({
        "candles": candles,
        "rsi": rsi,
        "k": k,
        "d": d,
        "crosses": signals::crosses(&k, &d),
        "divergences": divs,
        "partial": resample::last_bar_partial(candles, interval),
    });
    (payload, stats)
}

fn run() -> Result<PathBuf, String> {
    let args = parse_args()?;
    let daily = data::load_csv(&args.input)?;

    let mut intervals = serde_json::Map::new();
    for interval in Interval::ALL {
        let (payload, stats) = analyse(&resample::resample(&daily, interval), interval, &args);
        print!("{stats}");
        intervals.insert(interval.name().into(), payload);
    }
    let payload = json!({
        "name": args.input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(),
        "rsiPeriod": args.rsi,
        "kdPeriod": args.kd,
        "initial": args.interval.name(),
        "intervals": intervals,
    });
    // "</" would end the <script> element early.
    let json = payload.to_string().replace("</", "<\\/");
    let html = TEMPLATE.replace("/*__DATA__*/null", &json);

    let out = args.output.unwrap_or_else(|| args.input.with_extension("html"));
    std::fs::write(&out, html).map_err(|e| format!("{}: {e}", out.display()))?;
    Ok(out)
}

fn main() -> ExitCode {
    match run() {
        Ok(out) => {
            println!("wrote {}", out.display());
            ExitCode::SUCCESS
        }
        Err(e) => {
            if !e.is_empty() {
                eprintln!("error: {e}");
            }
            eprintln!("{USAGE}");
            ExitCode::FAILURE
        }
    }
}