mirror of
https://github.com/RomySaputraSihananda/ares.git
synced 2026-08-06 23:47:49 +00:00
feat: session filter, breakeven SL, daily loss limit, startup alert, multi-pair web
Bot improvements: - Session filter (SESSION_FROM_UTC/TO_UTC) — backtest confirms 08-13 UTC optimal for XAU - Breakeven SL management (BREAKEVEN_AT_RR) — disabled by default, hurts XAU momentum - Daily loss limit circuit breaker (DAILY_LOSS_LIMIT_PCT) - Telegram startup alert with symbol, session, risk, and balance - MT5 modify_position (TRADE_ACTION_SLTP) support in mt5-client Web updates: - Version badge auto-fetched from GitHub Releases API - GitHub icon link in Nav and footer - Multi-pair general (not XAUUSDm-specific) - BTCUSDm backtest results added, session params in params table - Trades page uses rolling 90-day window instead of hardcoded date Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,20 @@ BACKTEST_CANDLES=50000
|
||||
# LIVE=true
|
||||
# LIVE_POLL_SECS=30
|
||||
|
||||
# ── Optimisations ─────────────────────────────────────────────────────────────
|
||||
# Session filter: only take new entries when UTC hour in [FROM, TO).
|
||||
# Leave empty to disable. Recommended: 8-13 (London) based on live data.
|
||||
# SESSION_FROM_UTC=8
|
||||
# SESSION_TO_UTC=13
|
||||
|
||||
# Breakeven: move SL to entry once price moves this multiple of SL-distance.
|
||||
# 0 = disabled, 1.0 = move at 1:1 RR
|
||||
# BREAKEVEN_AT_RR=1.0
|
||||
|
||||
# Daily loss limit: halt new entries if today's closed PnL < -(balance × pct).
|
||||
# 0 = disabled, 0.03 = stop after -3% day
|
||||
# DAILY_LOSS_LIMIT_PCT=0.03
|
||||
|
||||
# ── Telegram Notifications ───────────────────────────────────────────────────
|
||||
# Get token from @BotFather, chat_id from @userinfobot or Telegram API.
|
||||
# All three are optional — omit to disable notifications.
|
||||
|
||||
@@ -97,6 +97,23 @@ impl Mt5Client {
|
||||
Ok(w.data)
|
||||
}
|
||||
|
||||
pub async fn modify_position(
|
||||
&self,
|
||||
ticket: u64,
|
||||
symbol: &str,
|
||||
sl: f64,
|
||||
tp: f64,
|
||||
) -> Result<TradeResult, Mt5Error> {
|
||||
let req = TradeRequest::modify_sltp(symbol, ticket, sl, tp);
|
||||
#[derive(serde::Serialize)]
|
||||
struct Body<'a> { request: &'a TradeRequest }
|
||||
let url = format!("{}/order/send", self.base_url);
|
||||
let text = self.fetch_text(self.http.post(&url).json(&Body { request: &req })).await?;
|
||||
tracing::debug!(endpoint = %url, ticket, "modify position ok");
|
||||
let w: DataOne<TradeResult> = serde_json::from_str(&text)?;
|
||||
Ok(w.data)
|
||||
}
|
||||
|
||||
pub async fn cancel_order(&self, ticket: u64, symbol: &str) -> Result<TradeResult, Mt5Error> {
|
||||
let req = TradeRequest::cancel(symbol, ticket);
|
||||
#[derive(serde::Serialize)]
|
||||
|
||||
@@ -31,6 +31,9 @@ pub struct TradeRequest {
|
||||
/// Ticket number — required for TRADE_ACTION_REMOVE (cancel pending order)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub order: Option<u64>,
|
||||
/// Position ticket — required for TRADE_ACTION_SLTP (modify SL/TP)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position: Option<u64>,
|
||||
/// Allowed price deviation in points
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deviation: Option<u32>,
|
||||
@@ -63,6 +66,25 @@ impl TradeRequest {
|
||||
magic: Some(magic),
|
||||
comment: Some(comment.into()),
|
||||
order: None,
|
||||
position: None,
|
||||
deviation: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn modify_sltp(symbol: impl Into<String>, position: u64, sl: f64, tp: f64) -> Self {
|
||||
// TRADE_ACTION_SLTP = 6
|
||||
Self {
|
||||
action: 6,
|
||||
symbol: symbol.into(),
|
||||
volume: None,
|
||||
order_type: None,
|
||||
price: None,
|
||||
sl: Some(sl),
|
||||
tp: Some(tp),
|
||||
magic: None,
|
||||
comment: None,
|
||||
order: None,
|
||||
position: Some(position),
|
||||
deviation: None,
|
||||
}
|
||||
}
|
||||
@@ -81,6 +103,7 @@ impl TradeRequest {
|
||||
magic: None,
|
||||
comment: None,
|
||||
order: Some(ticket),
|
||||
position: None,
|
||||
deviation: None,
|
||||
}
|
||||
}
|
||||
|
||||
+102
-25
@@ -1,5 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use chrono::NaiveDate;
|
||||
use chrono::{NaiveDate, Timelike};
|
||||
use domain::Side;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
@@ -10,26 +10,35 @@ use crate::helpers::{actual_entry, actual_exit, fmt_price, fmt_pnl, rolling_ema,
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BacktestConfig {
|
||||
pub timeframe: domain::Timeframe,
|
||||
pub candles: u32,
|
||||
pub balance: Decimal,
|
||||
pub risk_pct: Decimal,
|
||||
pub body_pct_min: Decimal,
|
||||
pub close_pct_min: Decimal,
|
||||
pub fvg_expiry: usize,
|
||||
pub min_fvg_pips: Decimal,
|
||||
pub min_sl_pips: Decimal,
|
||||
pub sl_buffer: Decimal,
|
||||
pub min_rr: Decimal,
|
||||
pub timeout_candles: usize,
|
||||
pub commission: Decimal,
|
||||
pub slippage_points: Decimal,
|
||||
pub spread_override: Option<Decimal>,
|
||||
pub ema_period: usize,
|
||||
pub date_from: Option<NaiveDate>,
|
||||
pub date_to: Option<NaiveDate>,
|
||||
pub stop_out_pct: Decimal,
|
||||
pub tf_str: String,
|
||||
pub timeframe: domain::Timeframe,
|
||||
pub candles: u32,
|
||||
pub balance: Decimal,
|
||||
pub risk_pct: Decimal,
|
||||
pub body_pct_min: Decimal,
|
||||
pub close_pct_min: Decimal,
|
||||
pub fvg_expiry: usize,
|
||||
pub min_fvg_pips: Decimal,
|
||||
pub min_sl_pips: Decimal,
|
||||
pub sl_buffer: Decimal,
|
||||
pub min_rr: Decimal,
|
||||
pub timeout_candles: usize,
|
||||
pub commission: Decimal,
|
||||
pub slippage_points: Decimal,
|
||||
pub spread_override: Option<Decimal>,
|
||||
pub ema_period: usize,
|
||||
pub date_from: Option<NaiveDate>,
|
||||
pub date_to: Option<NaiveDate>,
|
||||
pub stop_out_pct: Decimal,
|
||||
pub tf_str: String,
|
||||
// ── new optimisations ────────────────────────────────────────────────────
|
||||
/// UTC hour range [from, to) allowed for new entries. None = no filter.
|
||||
pub session_from_utc: Option<u32>,
|
||||
pub session_to_utc: Option<u32>,
|
||||
/// Move SL to entry once price moves this many × SL-distance in our favour.
|
||||
/// 0 = disabled.
|
||||
pub breakeven_at_rr: Decimal,
|
||||
/// Stop trading today when realised PnL < -(balance × this). 0 = disabled.
|
||||
pub daily_loss_limit_pct: Decimal,
|
||||
}
|
||||
|
||||
// ── open trade ────────────────────────────────────────────────────────────────
|
||||
@@ -43,6 +52,7 @@ struct OpenTrade {
|
||||
tp: Decimal,
|
||||
volume: Decimal,
|
||||
open_candle_idx: usize,
|
||||
be_set: bool, // breakeven already applied
|
||||
}
|
||||
|
||||
// ── entry point ───────────────────────────────────────────────────────────────
|
||||
@@ -97,12 +107,44 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig
|
||||
let mut max_consec = 0u32;
|
||||
let mut cur_consec = 0u32;
|
||||
|
||||
// ── daily loss tracking ───────────────────────────────────────────────────
|
||||
let mut today_date = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
|
||||
let mut today_start_balance = balance;
|
||||
let mut daily_halted = false;
|
||||
|
||||
'outer: for i in 2..total {
|
||||
let candle = &candles[i];
|
||||
let date = candle.time.date_naive();
|
||||
let hour = candle.time.time().hour();
|
||||
|
||||
// ── reset daily state on new day ──────────────────────────────────────
|
||||
if date != today_date {
|
||||
today_date = date;
|
||||
today_start_balance = balance;
|
||||
daily_halted = false;
|
||||
}
|
||||
|
||||
// ── manage open trade ────────────────────────────────────────────────
|
||||
if let Some(ref t) = open_trade {
|
||||
if let Some(ref mut t) = open_trade {
|
||||
// breakeven: move SL to entry once price moves breakeven_at_rr × sl_dist
|
||||
if !t.be_set && cfg.breakeven_at_rr > Decimal::ZERO {
|
||||
let sl_dist = (t.actual_entry - t.sl).abs();
|
||||
let be_trigger = match t.side {
|
||||
Side::Long => t.actual_entry + sl_dist * cfg.breakeven_at_rr,
|
||||
Side::Short => t.actual_entry - sl_dist * cfg.breakeven_at_rr,
|
||||
};
|
||||
let triggered = match t.side {
|
||||
Side::Long => candle.high >= be_trigger,
|
||||
Side::Short => candle.low <= be_trigger,
|
||||
};
|
||||
if triggered {
|
||||
t.sl = t.actual_entry;
|
||||
t.be_set = true;
|
||||
tracing::debug!(%symbol, "breakeven SL set");
|
||||
}
|
||||
}
|
||||
|
||||
// timeout
|
||||
if cfg.timeout_candles > 0 && (i - t.open_candle_idx) >= cfg.timeout_candles {
|
||||
let t = open_trade.take().unwrap();
|
||||
let exit_lvl = candle.close;
|
||||
@@ -132,6 +174,7 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig
|
||||
continue;
|
||||
}
|
||||
|
||||
// stop-out
|
||||
if cfg.stop_out_pct > Decimal::ZERO {
|
||||
let worst_price = match t.side {
|
||||
Side::Long => candle.low,
|
||||
@@ -204,8 +247,9 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig
|
||||
wins += 1; sum_wins += pnl; cur_consec = 0;
|
||||
}
|
||||
trades += 1; total_pnl += pnl; total_friction += friction;
|
||||
let be_tag = if t.be_set { " [BE]" } else { "" };
|
||||
println!(
|
||||
"[{} {}] {} {} entry={} sl={} tp={} vol={:.2} → {label} exit={} friction={} pnl={} bal={:.2}",
|
||||
"[{} {}] {} {} entry={} sl={} tp={} vol={:.2} → {label}{be_tag} exit={} friction={} pnl={} bal={:.2}",
|
||||
t.open_time, cfg.tf_str, symbol,
|
||||
if t.side == Side::Long { "LONG " } else { "SHORT" },
|
||||
fmt_price(t.actual_entry, prec), fmt_price(t.sl, prec), fmt_price(t.tp, prec), t.volume,
|
||||
@@ -219,6 +263,26 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig
|
||||
if cfg.date_from.is_some_and(|d| date < d) { continue; }
|
||||
if cfg.date_to.is_some_and(|d| date > d) { continue; }
|
||||
|
||||
// ── daily loss limit ──────────────────────────────────────────────────
|
||||
if daily_halted { continue; }
|
||||
if cfg.daily_loss_limit_pct > Decimal::ZERO {
|
||||
let daily_pnl = balance - today_start_balance;
|
||||
let limit = -(today_start_balance * cfg.daily_loss_limit_pct);
|
||||
if daily_pnl <= limit {
|
||||
daily_halted = true;
|
||||
tracing::debug!(%symbol, %date, "daily loss limit hit — halting rest of day");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── session filter ────────────────────────────────────────────────────
|
||||
if let (Some(from), Some(to)) = (cfg.session_from_utc, cfg.session_to_utc) {
|
||||
if hour < from || hour >= to {
|
||||
pending_fvg = None; // discard stale FVGs from outside session
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── expire stale FVG ──────────────────────────────────────────────────
|
||||
if pending_fvg.as_ref().is_some_and(|f| i >= f.expiry_idx) {
|
||||
missed_fills += 1;
|
||||
@@ -275,6 +339,7 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig
|
||||
actual_entry: ae,
|
||||
sl, tp, volume: v,
|
||||
open_candle_idx: i,
|
||||
be_set: false,
|
||||
});
|
||||
pending_fvg = None;
|
||||
}
|
||||
@@ -323,11 +388,23 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig
|
||||
let pf = if sum_losses > Decimal::ZERO { sum_wins / sum_losses } else { Decimal::MAX };
|
||||
let ret_pct = (balance - cfg.balance) / cfg.balance * Decimal::from(100u32);
|
||||
|
||||
let session_str = match (cfg.session_from_utc, cfg.session_to_utc) {
|
||||
(Some(f), Some(t)) => format!(" session={}–{}UTC", f, t),
|
||||
_ => String::new(),
|
||||
};
|
||||
let be_str = if cfg.breakeven_at_rr > Decimal::ZERO {
|
||||
format!(" be@{}×RR", cfg.breakeven_at_rr)
|
||||
} else { String::new() };
|
||||
let dl_str = if cfg.daily_loss_limit_pct > Decimal::ZERO {
|
||||
format!(" daily_loss_limit={}%", cfg.daily_loss_limit_pct * Decimal::from(100u32))
|
||||
} else { String::new() };
|
||||
|
||||
println!("─────────────────────────────────────────");
|
||||
println!("Ares Scalper: {} {} | {} candles", symbol, cfg.tf_str, total);
|
||||
let timeout_str = if cfg.timeout_candles > 0 { format!(" timeout={}c", cfg.timeout_candles) } else { String::new() };
|
||||
println!("Strategy : Momentum FVG body≥{} close≥{} expiry={}c min_fvg={}pip min_sl={}pip min_rr={}{}",
|
||||
cfg.body_pct_min, cfg.close_pct_min, cfg.fvg_expiry, cfg.min_fvg_pips, cfg.min_sl_pips, cfg.min_rr, timeout_str);
|
||||
println!("Strategy : Momentum FVG body≥{} close≥{} expiry={}c min_fvg={}pip min_sl={}pip min_rr={}{}{}{}{}",
|
||||
cfg.body_pct_min, cfg.close_pct_min, cfg.fvg_expiry, cfg.min_fvg_pips, cfg.min_sl_pips, cfg.min_rr,
|
||||
timeout_str, session_str, be_str, dl_str);
|
||||
println!("Friction : spread={} slip={} commission/lot={}",
|
||||
fmt_price(spread_price, prec), fmt_price(slippage_price, prec), cfg.commission);
|
||||
println!("─────────────────────────────────────────");
|
||||
|
||||
+127
-23
@@ -1,5 +1,5 @@
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono::{DateTime, Timelike, Utc};
|
||||
use domain::{Side, Timeframe};
|
||||
use futures_util::StreamExt;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -33,9 +33,14 @@ pub struct LiveConfig {
|
||||
pub slippage_points: Decimal,
|
||||
pub spread_override: Option<Decimal>,
|
||||
pub ema_period: usize,
|
||||
pub poll_secs: u64,
|
||||
pub mt5_base_url: String,
|
||||
pub telegram: Option<TelegramConfig>,
|
||||
pub poll_secs: u64,
|
||||
pub mt5_base_url: String,
|
||||
pub telegram: Option<TelegramConfig>,
|
||||
// ── optimisations ────────────────────────────────────────────────────────
|
||||
pub session_from_utc: Option<u32>,
|
||||
pub session_to_utc: Option<u32>,
|
||||
pub breakeven_at_rr: Decimal,
|
||||
pub daily_loss_limit_pct: Decimal,
|
||||
}
|
||||
|
||||
// ── pending order state ───────────────────────────────────────────────────────
|
||||
@@ -78,6 +83,8 @@ struct PosState {
|
||||
sl: f64,
|
||||
tp: f64,
|
||||
volume: f64,
|
||||
#[serde(default)]
|
||||
be_set: bool,
|
||||
}
|
||||
|
||||
impl PosState {
|
||||
@@ -169,6 +176,27 @@ pub async fn run(mt5: &mt5_client::Mt5Client, cfg: &LiveConfig) -> Result<()> {
|
||||
}
|
||||
|
||||
let http = reqwest::Client::new();
|
||||
|
||||
// startup notification
|
||||
if let Some(tg) = &cfg.telegram {
|
||||
let bal = mt5.account().await.ok()
|
||||
.map(|a| d2f(a.balance))
|
||||
.unwrap_or(0.0);
|
||||
let session_str = match (cfg.session_from_utc, cfg.session_to_utc) {
|
||||
(Some(f), Some(t)) => format!("{f:02}:00–{t:02}:00 UTC"),
|
||||
_ => "All hours".to_string(),
|
||||
};
|
||||
let text = format!(
|
||||
"🤖 <b>ARES Started</b>\n{} · {:?}\n\nSession <code>{}</code>\nRisk <code>{:.1}%</code>\nEMA <code>{}</code>\nBalance <code>${:.2}</code>",
|
||||
cfg.symbol, cfg.timeframe,
|
||||
session_str,
|
||||
d2f(cfg.risk_pct) * 100.0,
|
||||
cfg.ema_period,
|
||||
bal,
|
||||
);
|
||||
let _ = tg.send(&http, &text).await;
|
||||
}
|
||||
|
||||
let mut ticker = interval(Duration::from_secs(cfg.poll_secs));
|
||||
|
||||
loop {
|
||||
@@ -209,21 +237,67 @@ async fn tick(
|
||||
|
||||
if has_position {
|
||||
// ensure PosState exists so SSE task can find it on close
|
||||
if PosState::load(symbol).is_none() {
|
||||
if let Some(pos) = positions.iter().find(|p| p.symbol == *symbol && p.magic == MAGIC) {
|
||||
let tg_msg_id = State::load(symbol).and_then(|s| s.tg_message_id);
|
||||
let ps = PosState {
|
||||
ticket: pos.ticket,
|
||||
tg_message_id: tg_msg_id,
|
||||
side: format!("{:?}", pos.side),
|
||||
entry: d2f(pos.price_open),
|
||||
sl: d2f(pos.sl),
|
||||
tp: d2f(pos.tp),
|
||||
volume: d2f(pos.volume),
|
||||
};
|
||||
let _ = ps.save(symbol);
|
||||
let mut ps = if let Some(existing) = PosState::load(symbol) {
|
||||
existing
|
||||
} else if let Some(pos) = positions.iter().find(|p| p.symbol == *symbol && p.magic == MAGIC) {
|
||||
let tg_msg_id = State::load(symbol).and_then(|s| s.tg_message_id);
|
||||
let ps = PosState {
|
||||
ticket: pos.ticket,
|
||||
tg_message_id: tg_msg_id,
|
||||
side: format!("{:?}", pos.side),
|
||||
entry: d2f(pos.price_open),
|
||||
sl: d2f(pos.sl),
|
||||
tp: d2f(pos.tp),
|
||||
volume: d2f(pos.volume),
|
||||
be_set: false,
|
||||
};
|
||||
let _ = ps.save(symbol);
|
||||
ps
|
||||
} else {
|
||||
tracing::debug!(%symbol, "position open — skip");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// ── breakeven SL management ───────────────────────────────────────────
|
||||
if !ps.be_set && cfg.breakeven_at_rr > Decimal::ZERO {
|
||||
if let Some(pos) = positions.iter().find(|p| p.ticket == ps.ticket) {
|
||||
let entry = Decimal::try_from(ps.entry).unwrap_or_default();
|
||||
let sl = Decimal::try_from(ps.sl).unwrap_or_default();
|
||||
let sl_dist = (entry - sl).abs();
|
||||
if sl_dist > Decimal::ZERO {
|
||||
let be_trigger = match pos.side {
|
||||
domain::Side::Long => entry + sl_dist * cfg.breakeven_at_rr,
|
||||
domain::Side::Short => entry - sl_dist * cfg.breakeven_at_rr,
|
||||
};
|
||||
let reached = match pos.side {
|
||||
domain::Side::Long => pos.price_current >= be_trigger,
|
||||
domain::Side::Short => pos.price_current <= be_trigger,
|
||||
};
|
||||
if reached {
|
||||
let new_sl = ps.entry; // move SL to entry
|
||||
match mt5.modify_position(pos.ticket, symbol, new_sl, ps.tp).await {
|
||||
Ok(r) if r.retcode == 10009 => {
|
||||
tracing::info!(%symbol, ticket = pos.ticket, "breakeven SL set");
|
||||
ps.sl = new_sl;
|
||||
ps.be_set = true;
|
||||
let _ = ps.save(symbol);
|
||||
if let (Some(tg), Some(msg_id)) = (&cfg.telegram, ps.tg_message_id) {
|
||||
let text = format!(
|
||||
"🔒 <b>BREAKEVEN</b>\n{} · {}\n\nSL moved to entry <code>{}</code>\nTP <code>{}</code>",
|
||||
symbol, ps.side,
|
||||
fp(ps.entry), fp(ps.tp),
|
||||
);
|
||||
let _ = tg.edit(http, msg_id, &text).await;
|
||||
}
|
||||
}
|
||||
Ok(r) => tracing::warn!(retcode = r.retcode, "breakeven modify retcode unexpected"),
|
||||
Err(e) => tracing::warn!("breakeven modify failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(%symbol, "position open — skip");
|
||||
return Ok(());
|
||||
}
|
||||
@@ -260,7 +334,36 @@ async fn tick(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── 3. fetch candles ──────────────────────────────────────────────────────
|
||||
// ── 3. session filter ─────────────────────────────────────────────────────
|
||||
if let (Some(from), Some(to)) = (cfg.session_from_utc, cfg.session_to_utc) {
|
||||
let hour = Utc::now().time().hour();
|
||||
if hour < from || hour >= to {
|
||||
tracing::debug!(%symbol, hour, from, to, "outside session window — skip");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. daily loss limit ───────────────────────────────────────────────────
|
||||
if cfg.daily_loss_limit_pct > Decimal::ZERO {
|
||||
let now = Utc::now();
|
||||
let today_str = now.format("%Y-%m-%dT00:00:00").to_string();
|
||||
let now_str = now.format("%Y-%m-%dT%H:%M:%S").to_string();
|
||||
if let Ok(today_deals) = mt5.history_deals(&today_str, &now_str, Some(symbol)).await {
|
||||
let daily_pnl: Decimal = today_deals.iter()
|
||||
.filter(|d| d.entry == 1 && d.magic == MAGIC)
|
||||
.map(|d| d.profit + d.commission + d.swap)
|
||||
.sum();
|
||||
let acct = mt5.account().await.context("fetch account for daily limit")?;
|
||||
let balance = Decimal::try_from(acct.balance).context("balance")?;
|
||||
let limit = -(balance * cfg.daily_loss_limit_pct);
|
||||
if daily_pnl <= limit {
|
||||
tracing::info!(%symbol, %daily_pnl, %limit, "daily loss limit hit — no new trades today");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. fetch candles ──────────────────────────────────────────────────────
|
||||
let candles = mt5
|
||||
.rates_from_pos(symbol, cfg.timeframe, 0, CANDLE_FETCH)
|
||||
.await
|
||||
@@ -273,7 +376,7 @@ async fn tick(
|
||||
let impulse = &candles[n - 3];
|
||||
let post = &candles[n - 2];
|
||||
|
||||
// ── 4. EMA filter ─────────────────────────────────────────────────────────
|
||||
// ── 6. EMA filter ─────────────────────────────────────────────────────────
|
||||
let ema_val: Option<Decimal> = if cfg.ema_period > 0 && candles.len() >= cfg.ema_period {
|
||||
let closes: Vec<Decimal> = candles.iter().map(|c| c.close).collect();
|
||||
rolling_ema(&closes, cfg.ema_period)[n - 2]
|
||||
@@ -281,7 +384,7 @@ async fn tick(
|
||||
Some(Decimal::ZERO)
|
||||
};
|
||||
|
||||
// ── 5. detect FVG ─────────────────────────────────────────────────────────
|
||||
// ── 7. detect FVG ─────────────────────────────────────────────────────────
|
||||
let fvg = match detector::detect(
|
||||
pre, impulse, post,
|
||||
cfg.body_pct_min, cfg.close_pct_min, min_zone,
|
||||
@@ -300,7 +403,7 @@ async fn tick(
|
||||
};
|
||||
if !ema_ok { return Ok(()); }
|
||||
|
||||
// ── 6. SL / TP ────────────────────────────────────────────────────────────
|
||||
// ── 8. SL / TP ────────────────────────────────────────────────────────────
|
||||
let sl = match fvg.side {
|
||||
Side::Long => fvg.impulse_sl - cfg.sl_buffer,
|
||||
Side::Short => fvg.impulse_sl + cfg.sl_buffer,
|
||||
@@ -312,7 +415,7 @@ async fn tick(
|
||||
Side::Short => fvg.entry - sl_dist * cfg.min_rr,
|
||||
};
|
||||
|
||||
// ── 7. position size ──────────────────────────────────────────────────────
|
||||
// ── 9. position size ──────────────────────────────────────────────────────
|
||||
let acct = mt5.account().await.context("fetch account")?;
|
||||
let balance = Decimal::try_from(acct.balance).context("balance")?;
|
||||
let value_per_lot = if profit_is_usd || post.close == Decimal::ZERO {
|
||||
@@ -328,7 +431,7 @@ async fn tick(
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// ── 8. place order ────────────────────────────────────────────────────────
|
||||
// ── 10. place order ───────────────────────────────────────────────────────
|
||||
let req = mt5_client::TradeRequest::limit(
|
||||
fvg.side, symbol.clone(), d2f(volume), d2f(fvg.entry), d2f(sl), d2f(tp),
|
||||
MAGIC, format!("ares-{}", post.time.format("%m%d-%H%M")),
|
||||
@@ -487,6 +590,7 @@ async fn on_position_opened(
|
||||
tg_message_id: tg_msg_id,
|
||||
side: if kind == 0 { "Long".to_string() } else { "Short".to_string() },
|
||||
entry, sl, tp, volume,
|
||||
be_set: false,
|
||||
};
|
||||
let _ = ps.save(symbol);
|
||||
State::clear(symbol);
|
||||
|
||||
+37
-19
@@ -78,18 +78,28 @@ async fn main() -> anyhow::Result<()> {
|
||||
};
|
||||
|
||||
// ── shared config ─────────────────────────────────────────────────────────
|
||||
let risk_pct = env_dec("RISK_PCT", "0.01")?;
|
||||
let body_pct_min = env_dec("BODY_PCT_MIN", "0.6")?;
|
||||
let close_pct_min = env_dec("CLOSE_PCT_MIN", "0.8")?;
|
||||
let fvg_expiry = env_usize("FVG_EXPIRY_CANDLES", "10")?;
|
||||
let min_fvg_pips = env_dec("MIN_FVG_PIPS", "3")?;
|
||||
let min_sl_pips = env_dec("MIN_SL_PIPS", "5")?;
|
||||
let sl_buffer = env_dec("SL_BUFFER", "0")?;
|
||||
let min_rr = env_dec("MIN_RR", "1.5")?;
|
||||
let commission = env_dec("COMMISSION_PER_LOT", "0")?;
|
||||
let slippage_points = env_dec("SLIPPAGE_POINTS", "5")?;
|
||||
let spread_override = env_spread_override()?;
|
||||
let ema_period = env_usize("EMA_PERIOD", "20")?;
|
||||
let risk_pct = env_dec("RISK_PCT", "0.01")?;
|
||||
let body_pct_min = env_dec("BODY_PCT_MIN", "0.6")?;
|
||||
let close_pct_min = env_dec("CLOSE_PCT_MIN", "0.8")?;
|
||||
let fvg_expiry = env_usize("FVG_EXPIRY_CANDLES", "10")?;
|
||||
let min_fvg_pips = env_dec("MIN_FVG_PIPS", "3")?;
|
||||
let min_sl_pips = env_dec("MIN_SL_PIPS", "5")?;
|
||||
let sl_buffer = env_dec("SL_BUFFER", "0")?;
|
||||
let min_rr = env_dec("MIN_RR", "1.5")?;
|
||||
let commission = env_dec("COMMISSION_PER_LOT", "0")?;
|
||||
let slippage_points = env_dec("SLIPPAGE_POINTS", "5")?;
|
||||
let spread_override = env_spread_override()?;
|
||||
let ema_period = env_usize("EMA_PERIOD", "20")?;
|
||||
let breakeven_at_rr = env_dec("BREAKEVEN_AT_RR", "0")?;
|
||||
let daily_loss_limit_pct = env_dec("DAILY_LOSS_LIMIT_PCT", "0")?;
|
||||
let session_from_utc: Option<u32> = match std::env::var("SESSION_FROM_UTC") {
|
||||
Ok(s) if !s.is_empty() => Some(s.parse().context("SESSION_FROM_UTC")?),
|
||||
_ => None,
|
||||
};
|
||||
let session_to_utc: Option<u32> = match std::env::var("SESSION_TO_UTC") {
|
||||
Ok(s) if !s.is_empty() => Some(s.parse().context("SESSION_TO_UTC")?),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let mt5 = Arc::new(mt5_client::Mt5Client::new(mt5_base_url.clone()));
|
||||
|
||||
@@ -128,6 +138,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
poll_secs,
|
||||
mt5_base_url: mt5_base_url.clone(),
|
||||
telegram,
|
||||
session_from_utc,
|
||||
session_to_utc,
|
||||
breakeven_at_rr,
|
||||
daily_loss_limit_pct,
|
||||
};
|
||||
|
||||
let mut handles = Vec::new();
|
||||
@@ -148,8 +162,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
// ── backtest mode ─────────────────────────────────────────────────────────
|
||||
let cfg = backtest::BacktestConfig {
|
||||
timeframe,
|
||||
candles: env_u32("BACKTEST_CANDLES", "50000")?,
|
||||
balance: env_dec("BACKTEST_BALANCE", "600")?,
|
||||
candles: env_u32("BACKTEST_CANDLES", "50000")?,
|
||||
balance: env_dec("BACKTEST_BALANCE", "600")?,
|
||||
risk_pct,
|
||||
body_pct_min,
|
||||
close_pct_min,
|
||||
@@ -158,15 +172,19 @@ async fn main() -> anyhow::Result<()> {
|
||||
min_sl_pips,
|
||||
sl_buffer,
|
||||
min_rr,
|
||||
timeout_candles: env_usize("TIMEOUT_CANDLES", "0")?,
|
||||
timeout_candles: env_usize("TIMEOUT_CANDLES", "0")?,
|
||||
commission,
|
||||
slippage_points,
|
||||
spread_override,
|
||||
ema_period,
|
||||
date_from: env_date("DATE_FROM")?,
|
||||
date_to: env_date("DATE_TO")?,
|
||||
stop_out_pct: env_dec("STOP_OUT_PCT", "0.0")?,
|
||||
tf_str: tf_str.clone(),
|
||||
date_from: env_date("DATE_FROM")?,
|
||||
date_to: env_date("DATE_TO")?,
|
||||
stop_out_pct: env_dec("STOP_OUT_PCT", "0.0")?,
|
||||
tf_str: tf_str.clone(),
|
||||
session_from_utc,
|
||||
session_to_utc,
|
||||
breakeven_at_rr,
|
||||
daily_loss_limit_pct,
|
||||
};
|
||||
|
||||
for symbol in &symbols {
|
||||
|
||||
+18
-11
@@ -3,22 +3,27 @@ import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Backtest Results",
|
||||
description: "ARES backtest results: M5 Momentum FVG scalper on XAUUSDm. Best run: PF 1.42, +43.2% net return, −11.5% max drawdown.",
|
||||
description: "ARES backtest results: M5 Momentum FVG scalper on XAUUSDm and BTCUSDm. London session filter, EMA-20 trend filter.",
|
||||
};
|
||||
|
||||
const results = [
|
||||
{ period: "1 Month", tf: "M5", risk: "1%", trades: 159, wr: 55.3, pf: 1.42, ret: 43.2, dd: -11.5, highlight: true },
|
||||
{ period: "1 Month", tf: "M5", risk: "5%", trades: 159, wr: 55.3, pf: 1.26, ret: 390, dd: -156, highlight: false },
|
||||
{ period: "1 Week", tf: "M5", risk: "1%", trades: 34, wr: 47.1, pf: 0.94, ret: -6.2, dd: -8.1, highlight: false },
|
||||
{ period: "Yesterday", tf: "M1", risk: "1%", trades: 36, wr: 47.2, pf: 1.05, ret: 6.6, dd: -51, highlight: false },
|
||||
{ period: "Yesterday", tf: "M5", risk: "1%", trades: 3, wr: 66.7, pf: null, ret: null, dd: null, highlight: false, note: "Too few trades" },
|
||||
const results: Array<{
|
||||
symbol: string; period: string; tf: string; risk: string;
|
||||
trades: number | null; wr: number; pf: number | null; ret: number | null; dd: number | null;
|
||||
highlight: boolean; note?: string;
|
||||
}> = [
|
||||
{ symbol: "XAUUSDm", period: "1 Month", tf: "M5", risk: "1%", trades: 159, wr: 55.3, pf: 1.42, ret: 43.2, dd: -11.5, highlight: true },
|
||||
{ symbol: "XAUUSDm", period: "50k bars", tf: "M5", risk: "5%", trades: 1421, wr: 50.3, pf: 1.17, ret: null, dd: null, highlight: false, note: "Session 08–13 UTC" },
|
||||
{ symbol: "BTCUSDm", period: "50k bars", tf: "M5", risk: "5%", trades: null, wr: 56.9, pf: 1.11, ret: null, dd: null, highlight: false, note: "Session 08–13 UTC" },
|
||||
{ symbol: "XAUUSDm", period: "1 Month", tf: "M5", risk: "5%", trades: 159, wr: 55.3, pf: 1.26, ret: 390, dd: -156, highlight: false },
|
||||
{ symbol: "XAUUSDm", period: "1 Week", tf: "M5", risk: "1%", trades: 34, wr: 47.1, pf: 0.94, ret: -6.2, dd: -8.1, highlight: false },
|
||||
];
|
||||
|
||||
const params = [
|
||||
["Timeframe", "M5"],
|
||||
["Symbol", "XAUUSDm"],
|
||||
["Symbols", "XAUUSDm · BTCUSDm"],
|
||||
["Session", "08:00–13:00 UTC"],
|
||||
["EMA Period", "20"],
|
||||
["Min FVG Pips", "3"],
|
||||
["Min FVG Pips", "1"],
|
||||
["Min SL Pips", "5"],
|
||||
["Min RR", "1.5×"],
|
||||
["FVG Expiry", "10 candles"],
|
||||
@@ -36,6 +41,7 @@ export default function BacktestPage() {
|
||||
</h1>
|
||||
<p className="text-[15px] text-ink-sub max-w-xl">
|
||||
Historical simulation on real MT5 tick data. Includes spread costs, commission, and slippage.
|
||||
London session filter (08–13 UTC) applied.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -74,7 +80,7 @@ export default function BacktestPage() {
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{["Period", "TF", "Risk", "Trades", "Win Rate", "Profit Factor", "Return", "Max DD", ""].map(h => (
|
||||
{["Symbol", "Period", "TF", "Risk", "Trades", "Win Rate", "Profit Factor", "Return", "Max DD", ""].map(h => (
|
||||
<th key={h}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -82,10 +88,11 @@ export default function BacktestPage() {
|
||||
<tbody>
|
||||
{results.map((r, i) => (
|
||||
<tr key={i} className={r.highlight ? "bg-s2" : ""}>
|
||||
<td className="font-mono font-medium text-ink">{r.symbol}</td>
|
||||
<td className="font-medium text-ink">{r.period}</td>
|
||||
<td className="font-mono text-ink-sub">{r.tf}</td>
|
||||
<td className="font-mono text-ink-sub">{r.risk}</td>
|
||||
<td className="font-mono text-ink-md">{r.trades}</td>
|
||||
<td className="font-mono text-ink-md">{r.trades ?? "—"}</td>
|
||||
<td className="font-mono text-ink-md">{r.wr.toFixed(1)}%</td>
|
||||
<td className="font-mono font-medium">
|
||||
{r.pf != null
|
||||
|
||||
+36
-12
@@ -1,21 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import Nav from "@/components/Nav";
|
||||
import { getLatestVersion, GITHUB_URL } from "@/lib/github";
|
||||
|
||||
const SITE_URL = "https://ares.romys.my.id";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: "ARES — Automated Gold Trading Bot",
|
||||
default: "ARES — Algorithmic Trading Bot",
|
||||
template: "%s · ARES",
|
||||
},
|
||||
description:
|
||||
"Live forward-test results for ARES, an M5 Momentum FVG scalper built in Rust. Trades XAUUSDm with EMA-20 trend filter and automated risk management.",
|
||||
"Open-source algorithmic trading bot built in Rust. Momentum FVG scalper with EMA trend filter, automated risk management, and live forward-test results.",
|
||||
keywords: [
|
||||
"algorithmic trading", "gold trading bot", "XAUUSD EA",
|
||||
"algorithmic trading", "trading bot", "open source", "XAUUSD",
|
||||
"MT5 expert advisor", "Rust trading bot", "FVG scalper",
|
||||
"forex robot", "automated trading", "ICT strategy",
|
||||
"forex robot", "automated trading", "momentum strategy",
|
||||
],
|
||||
authors: [{ name: "Romy Saputra Sihananda" }],
|
||||
creator: "Romy Saputra Sihananda",
|
||||
@@ -28,14 +29,14 @@ export const metadata: Metadata = {
|
||||
type: "website",
|
||||
url: SITE_URL,
|
||||
siteName: "ARES Trading Bot",
|
||||
title: "ARES — Automated Gold Trading Bot",
|
||||
description: "Live forward-test · M5 Momentum FVG scalper · XAUUSDm · Built in Rust",
|
||||
title: "ARES — Algorithmic Trading Bot",
|
||||
description: "Open-source Momentum FVG scalper built in Rust · Live forward-test · MT5",
|
||||
locale: "en_US",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "ARES — Automated Gold Trading Bot",
|
||||
description: "Live forward-test · M5 Momentum FVG scalper · XAUUSDm · Built in Rust",
|
||||
title: "ARES — Algorithmic Trading Bot",
|
||||
description: "Open-source Momentum FVG scalper built in Rust · Live forward-test · MT5",
|
||||
},
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
@@ -46,11 +47,13 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const version = await getLatestVersion();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-screen antialiased">
|
||||
<Nav />
|
||||
<Nav version={version} />
|
||||
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-12">
|
||||
{children}
|
||||
</main>
|
||||
@@ -64,10 +67,26 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<path d="M10.2 15.5 H15.8" stroke="white" strokeWidth="1.8" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<span className="font-mono text-sm font-semibold" style={{ letterSpacing: "0.18em", color: "var(--c-ink)" }}>ARES</span>
|
||||
{version && (
|
||||
<span className="text-[10px] font-mono text-ink-ter bg-s2 border border-hl px-1.5 py-0.5 rounded">
|
||||
{version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed" style={{ color: "var(--c-ink-sub)" }}>
|
||||
M5 Momentum FVG Scalper<br />Built in Rust · XAUUSDm
|
||||
<p className="text-sm leading-relaxed mb-3" style={{ color: "var(--c-ink-sub)" }}>
|
||||
Open-source algorithmic trading bot<br />built in Rust · M5 Momentum FVG
|
||||
</p>
|
||||
<a
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-ink-sub hover:text-ink transition-colors"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/>
|
||||
</svg>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<p className="eyebrow mb-4">Navigation</p>
|
||||
@@ -75,6 +94,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<li><a href="/" className="hover:text-ink transition-colors">Dashboard</a></li>
|
||||
<li><a href="/trades" className="hover:text-ink transition-colors">Trades</a></li>
|
||||
<li><a href="/backtest" className="hover:text-ink transition-colors">Backtest</a></li>
|
||||
<li>
|
||||
<a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" className="hover:text-ink transition-colors">
|
||||
GitHub ↗
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
+3
-2
@@ -11,8 +11,9 @@ export default function DashboardPage() {
|
||||
ARES Trading Bot
|
||||
</h1>
|
||||
<p className="text-[18px] text-ink-md leading-relaxed max-w-2xl mb-8">
|
||||
M5 Momentum FVG scalper built in Rust. Targets Fair Value Gaps on XAUUSDm
|
||||
with EMA-20 trend filter, automatic position sizing, and Telegram alerts.
|
||||
Open-source algorithmic trading bot built in Rust. Momentum FVG scalper
|
||||
with EMA trend filter, configurable session window, automatic position
|
||||
sizing, and Telegram alerts — runs on any MT5 symbol.
|
||||
</p>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Link href="/trades" className="btn-primary">View Trades</Link>
|
||||
|
||||
@@ -6,7 +6,7 @@ import TradesRefresher from "@/components/TradesRefresher";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Trade History",
|
||||
description: "Live closed trades and equity curve for ARES — M5 Momentum FVG scalper on XAUUSDm.",
|
||||
description: "Live closed trades and equity curve for ARES — M5 Momentum FVG scalper on XAUUSDm and BTCUSDm.",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -20,7 +20,7 @@ export default async function TradesPage() {
|
||||
try {
|
||||
[account, deals] = await Promise.all([
|
||||
getAccount(),
|
||||
getAllDeals("2026-06-10T00:00:00"),
|
||||
getAllDeals(),
|
||||
]);
|
||||
} catch { error = true; }
|
||||
|
||||
|
||||
+26
-7
@@ -2,6 +2,7 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import { GITHUB_URL } from "@/lib/github";
|
||||
|
||||
const links = [
|
||||
{ href: "/", label: "Dashboard" },
|
||||
@@ -9,10 +10,10 @@ const links = [
|
||||
{ href: "/backtest", label: "Backtest" },
|
||||
];
|
||||
|
||||
export default function Nav() {
|
||||
export default function Nav({ version }: { version?: string | null }) {
|
||||
const path = usePathname();
|
||||
return (
|
||||
<nav className="sticky top-0 z-50" style={{ backgroundColor: 'var(--c-canvas)', borderBottom: '1px solid var(--c-hl)' }}>
|
||||
<nav className="sticky top-0 z-50" style={{ backgroundColor: "var(--c-canvas)", borderBottom: "1px solid var(--c-hl)" }}>
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 h-14 flex items-center gap-6">
|
||||
{/* logo + wordmark */}
|
||||
<Link href="/" className="flex items-center gap-2 group">
|
||||
@@ -21,9 +22,14 @@ export default function Nav() {
|
||||
<path d="M8 19 L13 8 L18 19" stroke="white" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M10.2 15.5 H15.8" stroke="white" strokeWidth="1.8" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<span className="font-mono text-sm font-semibold tracking-widest text-ink group-hover:text-accent transition-colors" style={{ letterSpacing: '0.18em' }}>
|
||||
<span className="font-mono text-sm font-semibold tracking-widest text-ink group-hover:text-accent transition-colors" style={{ letterSpacing: "0.18em" }}>
|
||||
ARES
|
||||
</span>
|
||||
{version && (
|
||||
<span className="hidden sm:inline text-[10px] font-mono text-ink-ter bg-s2 border border-hl px-1.5 py-0.5 rounded">
|
||||
{version}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* nav links */}
|
||||
@@ -44,10 +50,23 @@ export default function Nav() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* live indicator */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-bull pulse-dot" />
|
||||
<span className="text-xs text-ink-sub font-medium tracking-eyebrow uppercase">Live</span>
|
||||
{/* right side: github + live */}
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
<a
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-ink-ter hover:text-ink transition-colors"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-bull pulse-dot" />
|
||||
<span className="text-xs text-ink-sub font-medium tracking-eyebrow uppercase">Live</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const REPO = "RomySaputraSihananda/ares";
|
||||
|
||||
export async function getLatestVersion(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.github.com/repos/${REPO}/releases/latest`,
|
||||
{
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
next: { revalidate: 3600 },
|
||||
}
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return (data.tag_name as string) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const GITHUB_URL = `https://github.com/${REPO}`;
|
||||
export const GITHUB_REPO = REPO;
|
||||
+5
-2
@@ -107,8 +107,11 @@ export async function getDeals(dateFrom: string, dateTo: string, symbol?: string
|
||||
|
||||
// Fetch all deals from startDate to now by querying one day at a time,
|
||||
// working around the MT5 bridge per-request deal limit.
|
||||
export async function getAllDeals(startDate: string): Promise<Deal[]> {
|
||||
const start = new Date(startDate);
|
||||
// startDate defaults to 90 days ago if omitted.
|
||||
export async function getAllDeals(startDate?: string): Promise<Deal[]> {
|
||||
const start = startDate
|
||||
? new Date(startDate)
|
||||
: (() => { const d = new Date(); d.setUTCDate(d.getUTCDate() - 90); return d; })();
|
||||
const now = new Date();
|
||||
const days: Array<[string, string]> = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user