summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJSDurand <mmemmew@gmail.com>2026-10-12 18:41:33 +0800
committerJSDurand <mmemmew@gmail.com>2026-10-12 18:41:33 +0800
commit5c00355c2ccd94195bdb90e1c1e6f51acbcdd7a5 (patch)
treed9e10c99a2f20de29cfa281275160b4194d175f2
parent8c93a80302cee7ac73b9d4f2e90e47278164663f (diff)
added monthly and weekly analyses
* src/main.rs: * src/resample.rs: * src/signals.rs: * web/template.html (Stock RSI / KD):
-rw-r--r--src/main.rs61
-rw-r--r--src/resample.rs159
-rw-r--r--src/signals.rs4
-rw-r--r--web/template.html24
4 files changed, 221 insertions, 27 deletions
diff --git a/src/main.rs b/src/main.rs
index 8ca9df5..f48b35a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,13 +1,15 @@
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]";
+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.
@@ -19,12 +21,13 @@ struct Args {
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) = (2.5, None, 0);
+ 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() {
@@ -34,6 +37,10 @@ fn parse_args() -> Result<Args, String> {
"--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)),
@@ -42,39 +49,55 @@ fn parse_args() -> Result<Args, String> {
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 })
+ Ok(Args { input: input.ok_or("missing input.csv")?, output, rsi, kd, atr_k, swing, smooth, interval })
}
-fn run() -> Result<PathBuf, String> {
- let args = parse_args()?;
- let candles = data::load_csv(&args.input)?;
- let rsi = indicators::rsi(&candles, args.rsi);
- let (k, d) = indicators::kd(&candles, args.kd);
-
+/// 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) },
+ 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)
+ let found = signals::zigzag(&indicators::ema_candles(candles, args.smooth), &thr);
+ signals::snap_swings(candles, &found, args.smooth)
} else {
- signals::zigzag(&candles, &thr)
+ 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()));
+ 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);
- print!("{}", signals::forward_stats(&candles, &divs, &[5, 10, 20]));
+ let stats = format!("[{}] {} bars\n{}", interval.name(), candles.len(), signals::forward_stats(candles, &divs, &[5, 10, 20]));
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,
"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("</", "<\\/");
diff --git a/src/resample.rs b/src/resample.rs
new file mode 100644
index 0000000..74894bb
--- /dev/null
+++ b/src/resample.rs
@@ -0,0 +1,159 @@
+use crate::data::Candle;
+
+#[derive(Clone, Copy, PartialEq, Debug)]
+pub enum Interval {
+ Day,
+ Week,
+ Month,
+}
+
+impl Interval {
+ pub const ALL: [Interval; 3] = [Interval::Day, Interval::Week, Interval::Month];
+
+ pub fn name(self) -> &'static str {
+ match self {
+ Interval::Day => "day",
+ Interval::Week => "week",
+ Interval::Month => "month",
+ }
+ }
+
+ pub fn parse(s: &str) -> Option<Interval> {
+ Interval::ALL.into_iter().find(|i| i.name() == s)
+ }
+}
+
+/// Days since 1970-01-01 for a `YYYY-MM-DD` date (Howard Hinnant's civil-days algorithm).
+fn days_from_date(date: &str) -> i64 {
+ let (y, m, d): (i64, i64, i64) = (
+ date[0..4].parse().unwrap_or(1970),
+ date[5..7].parse().unwrap_or(1),
+ date[8..10].parse().unwrap_or(1),
+ );
+ let y = if m <= 2 { y - 1 } else { y };
+ let era = y.div_euclid(400);
+ let yoe = y - era * 400;
+ let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+ era * 146097 + doe - 719468
+}
+
+/// Monday = 0 ... Sunday = 6.
+fn weekday(date: &str) -> i64 {
+ (days_from_date(date) + 3).rem_euclid(7)
+}
+
+/// Bars are grouped by Monday-based week (1970-01-01 was a Thursday) or by calendar month.
+fn group_key(date: &str, interval: Interval) -> i64 {
+ match interval {
+ Interval::Day => days_from_date(date),
+ Interval::Week => (days_from_date(date) + 3).div_euclid(7),
+ Interval::Month => date[0..4].parse::<i64>().unwrap_or(0) * 12 + date[5..7].parse::<i64>().unwrap_or(1),
+ }
+}
+
+/// Aggregate daily candles: first open, highest high, lowest low, last close, summed volume;
+/// `date` is the last trading day of the group.
+pub fn resample(candles: &[Candle], interval: Interval) -> Vec<Candle> {
+ if interval == Interval::Day {
+ return candles.to_vec();
+ }
+ let mut out: Vec<Candle> = Vec::new();
+ let mut last_key = None;
+ for c in candles {
+ let key = group_key(&c.date, interval);
+ match (last_key == Some(key), out.last_mut()) {
+ (true, Some(bar)) => {
+ bar.high = bar.high.max(c.high);
+ bar.low = bar.low.min(c.low);
+ bar.close = c.close;
+ bar.volume += c.volume;
+ bar.date = c.date.clone();
+ }
+ _ => out.push(c.clone()),
+ }
+ last_key = Some(key);
+ }
+ out
+}
+
+/// Whether the last bar may still be incomplete: a week whose last trading day is before
+/// Friday, or a month whose last trading day is not on or after the 28th-ish last weekday.
+/// (Exchange holidays are not known, so this is a hint, not a guarantee.)
+pub fn last_bar_partial(candles: &[Candle], interval: Interval) -> bool {
+ let Some(last) = candles.last() else { return false };
+ match interval {
+ Interval::Day => false,
+ Interval::Week => weekday(&last.date) < 4,
+ Interval::Month => {
+ // last weekday of the month: step back from the month's last day
+ let (y, m): (i64, i64) = (last.date[0..4].parse().unwrap_or(0), last.date[5..7].parse().unwrap_or(1));
+ let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
+ let first_next = days_from_date(&format!("{ny:04}-{nm:02}-01"));
+ let mut d = first_next - 1;
+ while (d + 3).rem_euclid(7) >= 5 {
+ d -= 1;
+ }
+ days_from_date(&last.date) < d - 3 // allow a few holiday days before the last weekday
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn c(date: &str, o: f64, h: f64, l: f64, cl: f64, v: f64) -> Candle {
+ Candle { date: date.into(), open: o, high: h, low: l, close: cl, volume: v }
+ }
+
+ #[test]
+ fn weekday_known_dates() {
+ assert_eq!(weekday("1970-01-01"), 3); // Thursday
+ assert_eq!(weekday("2024-01-01"), 0); // Monday
+ assert_eq!(weekday("2026-10-12"), 0); // Monday
+ assert_eq!(weekday("2024-02-29"), 3); // leap day, Thursday
+ }
+
+ #[test]
+ fn weekly_spans_year_boundary_and_holiday() {
+ // 2023-12-28 Thu, 12-29 Fri | 2024-01-01 Mon (holiday, absent), 01-02 Tue, 01-05 Fri | 01-08 Mon
+ let days = [
+ c("2023-12-28", 10.0, 12.0, 9.0, 11.0, 100.0),
+ c("2023-12-29", 11.0, 13.0, 10.0, 12.0, 200.0),
+ c("2024-01-02", 12.0, 14.0, 11.0, 13.0, 300.0),
+ c("2024-01-05", 13.0, 15.0, 8.0, 9.0, 400.0),
+ c("2024-01-08", 9.0, 10.0, 8.5, 9.5, 500.0),
+ ];
+ let w = resample(&days, Interval::Week);
+ assert_eq!(w.len(), 3);
+ assert_eq!((w[0].open, w[0].high, w[0].low, w[0].close, w[0].volume), (10.0, 13.0, 9.0, 12.0, 300.0));
+ assert_eq!(w[0].date, "2023-12-29");
+ assert_eq!((w[1].open, w[1].high, w[1].low, w[1].close, w[1].volume), (12.0, 15.0, 8.0, 9.0, 700.0));
+ assert_eq!(w[1].date, "2024-01-05");
+ assert_eq!(w[2].date, "2024-01-08");
+ }
+
+ #[test]
+ fn monthly_groups_by_calendar_month() {
+ let days = [
+ c("2024-01-30", 1.0, 2.0, 0.5, 1.5, 10.0),
+ c("2024-01-31", 1.5, 3.0, 1.0, 2.0, 10.0),
+ c("2024-02-01", 2.0, 2.5, 1.5, 2.2, 10.0),
+ ];
+ let m = resample(&days, Interval::Month);
+ assert_eq!(m.len(), 2);
+ assert_eq!((m[0].high, m[0].low, m[0].close, m[0].volume), (3.0, 0.5, 2.0, 20.0));
+ assert_eq!(m[0].date, "2024-01-31");
+ }
+
+ #[test]
+ fn partial_flags() {
+ let thu = [c("2026-10-08", 1.0, 1.0, 1.0, 1.0, 1.0)];
+ let fri = [c("2026-10-09", 1.0, 1.0, 1.0, 1.0, 1.0)];
+ assert!(last_bar_partial(&thu, Interval::Week));
+ assert!(!last_bar_partial(&fri, Interval::Week));
+ assert!(last_bar_partial(&thu, Interval::Month)); // 2026-10-30 is the last weekday
+ assert!(!last_bar_partial(&[c("2026-10-30", 1.0, 1.0, 1.0, 1.0, 1.0)], Interval::Month));
+ }
+}
diff --git a/src/signals.rs b/src/signals.rs
index 3019c5c..00072b8 100644
--- a/src/signals.rs
+++ b/src/signals.rs
@@ -273,11 +273,11 @@ pub fn forward_stats(candles: &[Candle], divs: &[Divergence], horizons: &[usize]
})
.collect();
if rets.is_empty() {
- out += &format!(" +{h}d: n/a");
+ out += &format!(" +{h}b: n/a");
} else {
let mean = rets.iter().sum::<f64>() / rets.len() as f64 * 100.0;
let win = rets.iter().filter(|&&r| r > 0.0).count() as f64 / rets.len() as f64 * 100.0;
- out += &format!(" +{h}d: {mean:+.2}% win {win:.0}% (n={})", rets.len());
+ out += &format!(" +{h}b: {mean:+.2}% win {win:.0}% (n={})", rets.len());
}
}
out.push('\n');
diff --git a/web/template.html b/web/template.html
index 56bbea5..45fa874 100644
--- a/web/template.html
+++ b/web/template.html
@@ -35,6 +35,8 @@
<body>
<header>
<h1 id="title"></h1>
+ <label>Bars <select id="interval"><option value="day">Daily</option><option value="week">Weekly</option>
+ <option value="month">Monthly</option></select></label>
<label>Colours <select id="scheme"><option value="tw">Red up / green down (TW)</option>
<option value="us">Green up / red down (US)</option></select></label>
<button id="reset">Reset zoom</button>
@@ -44,19 +46,28 @@
</header>
<div id="wrap"><svg id="svg"></svg></div>
<script>
-const D = /*__DATA__*/null;
-const N = D.candles.length;
+const RAW = /*__DATA__*/null;
const $ = id => document.getElementById(id);
const svg = $('svg');
const M = { l: 12, r: 52, t: 6, b: 20 };
-const MIN_SPAN = Math.min(10, N);
+const UNIT = { day: 'day', week: 'week ending', month: 'month ending' };
-let start = Math.max(0, N - 120), end = N; // visible window, in bar indices (float)
+let D, N, MIN_SPAN, start, end; // current interval's data; visible window in bar indices (float)
let hover = null, pins = [];
+function useInterval(name) {
+ D = Object.assign({ name: RAW.name, rsiPeriod: RAW.rsiPeriod, kdPeriod: RAW.kdPeriod, interval: name }, RAW.intervals[name]);
+ N = D.candles.length;
+ MIN_SPAN = Math.min(10, N);
+ start = Math.max(0, N - 120); end = N;
+ hover = null; pins = [];
+ $('interval').value = name;
+ $('title').textContent = `${D.name} · ${name === 'day' ? 'daily' : name === 'week' ? 'weekly' : 'monthly'} bars · ` +
+ `RSI(${D.rsiPeriod}) KD(${D.kdPeriod}) · ${D.candles[0].Date} – ${D.candles[N-1].Date}`;
+}
let scheme = 'tw';
try { scheme = localStorage.getItem('scheme') || 'tw'; } catch (e) {}
$('scheme').value = scheme;
-$('title').textContent = `${D.name} · RSI(${D.rsiPeriod}) KD(${D.kdPeriod}) · ${D.candles[0].Date} – ${D.candles[N-1].Date}`;
+useInterval(RAW.initial);
const css = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
const fmt = (v, d = 2) => v == null ? '–' : v.toFixed(d);
@@ -205,7 +216,7 @@ function updateInfo() {
const prev = i > 0 ? D.candles[i-1].Close : null;
const chg = prev ? (c.Close - prev) / prev * 100 : null;
const cc = chg == null ? '' : `style="color:${chg >= 0 ? col.up : col.down}"`;
- let t = `<b>${c.Date}</b> &nbsp; O ${fmt(c.Open)} &nbsp; H ${fmt(c.High)} &nbsp; L ${fmt(c.Low)} &nbsp; ` +
+ let t = `<b>${D.interval === 'day' ? '' : UNIT[D.interval] + ' '}${c.Date}</b>${i === N - 1 && D.partial ? ' <span style="color:var(--muted)">(latest bar may be incomplete)</span>' : ''} &nbsp; O ${fmt(c.Open)} &nbsp; H ${fmt(c.High)} &nbsp; L ${fmt(c.Low)} &nbsp; ` +
`C <b ${cc}>${fmt(c.Close)}</b> <span ${cc}>(${chg == null ? '–' : (chg >= 0 ? '+' : '') + chg.toFixed(2) + '%'})</span> &nbsp; ` +
`Vol ${fmtVol(c.Volume)} &nbsp; <span style="color:${css('--accent')}">RSI ${fmt(D.rsi[i], 1)}</span> &nbsp; ` +
`<span style="color:${css('--line1')}">K ${fmt(D.k[i], 1)}</span> &nbsp; <span style="color:${css('--line2')}">D ${fmt(D.d[i], 1)}</span>`;
@@ -258,6 +269,7 @@ svg.addEventListener('pointerup', e => {
drag = null;
});
svg.addEventListener('pointerleave', () => { if (!drag) { hover = null; render(); } });
+$('interval').onchange = e => { useInterval(e.target.value); clampWin(); render(); };
$('reset').onclick = () => { start = 0; end = N; render(); };
$('clear').onclick = () => { pins = []; render(); };
$('scheme').onchange = e => { scheme = e.target.value; try { localStorage.setItem('scheme', scheme); } catch (_) {} render(); };