3f60efdc33
- TCMB (döviz alış kuru) ve xaus.com (altın) saatlik arka planda çekilir, gün başına saklanır - Kaynak erişilemezse son kur geçerli; altın için son USD kuru kullanılır - Elle kur girişi/silme; aynı gün elle girilen kur önceliklidir - GET /portfolio: güncel değer, kâr/zarar, toplamlar, son kur çekimi - RATES_REFRESH_MINUTES ayarı Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
7.0 KiB
Rust
215 lines
7.0 KiB
Rust
//! Kur kaynaklarının ayrıştırıcıları (saf; ağ yok → örnek metinle test edilir).
|
||
//!
|
||
//! - **TCMB** `today.xml`: dövizlerin **alış** kuru (`ForexBuying`) ÷ `Unit` (JPY 100 birim…).
|
||
//! Değerleme alış kuruyla (elindekini bugün bozdursan alacağın; kullanıcı kararı).
|
||
//! - **xaus.com** `/api/v1/spot`: 1 gr saf altının USD fiyatı (`per_gram_usd`) × TCMB USD alış.
|
||
//! Çeyrek/yarım/tam = içerdiği saf altın (has) gramı × gram fiyatı (kuyumcu işçilik payı hariç);
|
||
//! ons = 31,1034768 gr. Bu yaklaşık değer; gerekirse manuel kur girilir.
|
||
|
||
use serde::Deserialize;
|
||
|
||
use crate::services::calendar::{format_date, parse_date};
|
||
|
||
/// Altın kodları ve içerdiği saf altın (gr × 1e6)
|
||
pub const GOLD: &[(&str, i64)] = &[
|
||
("XAU_GRAM", 1_000_000),
|
||
("XAU_CEYREK", 1_603_000), // 1,75 gr × 0,916 (22 ayar)
|
||
("XAU_YARIM", 3_206_000), // 3,50 gr × 0,916
|
||
("XAU_TAM", 6_412_000), // 7,00 gr × 0,916
|
||
("XAU_ONS", 31_103_477), // 31,1034768 gr
|
||
];
|
||
|
||
#[derive(Debug, PartialEq, Eq)]
|
||
pub struct Bulletin {
|
||
/// `"YYYY-MM-DD"`
|
||
pub date: String,
|
||
/// (kod, TL × 1e6 / birim)
|
||
pub rates: Vec<(String, i64)>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct TcmbXml {
|
||
#[serde(rename = "@Tarih")]
|
||
tarih: String,
|
||
#[serde(rename = "Currency", default)]
|
||
currencies: Vec<TcmbCurrency>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct TcmbCurrency {
|
||
#[serde(rename = "@CurrencyCode")]
|
||
code: String,
|
||
#[serde(rename = "Unit")]
|
||
unit: Option<String>,
|
||
#[serde(rename = "ForexBuying")]
|
||
forex_buying: Option<String>,
|
||
}
|
||
|
||
/// `"50.1691"` → `50_169_100` (ondalık noktalı metin → × 1e6, kayan nokta kullanmadan)
|
||
pub fn decimal_e6(s: &str) -> Option<i64> {
|
||
let s = s.trim();
|
||
let (int, frac) = s.split_once('.').unwrap_or((s, ""));
|
||
if int.is_empty()
|
||
|| !int.bytes().all(|b| b.is_ascii_digit())
|
||
|| !frac.bytes().all(|b| b.is_ascii_digit())
|
||
{
|
||
return None;
|
||
}
|
||
let mut frac6: String = frac.chars().take(6).collect();
|
||
while frac6.len() < 6 {
|
||
frac6.push('0');
|
||
}
|
||
let v = int.parse::<i64>().ok()?.checked_mul(1_000_000)? + frac6.parse::<i64>().ok()?;
|
||
// 7. basamak yuvarlama
|
||
let round = frac.as_bytes().get(6).is_some_and(|b| *b >= b'5');
|
||
Some(v + i64::from(round))
|
||
}
|
||
|
||
pub fn parse_tcmb(xml: &str) -> Result<Bulletin, String> {
|
||
let doc: TcmbXml =
|
||
quick_xml::de::from_str(xml).map_err(|e| format!("TCMB XML okunamadı: {e}"))?;
|
||
// "12.11.2026" → "2026-11-12"
|
||
let parts: Vec<&str> = doc.tarih.split('.').collect();
|
||
let date = match parts.as_slice() {
|
||
[d, m, y] => format!("{y}-{m}-{d}"),
|
||
_ => return Err(format!("TCMB tarihi okunamadı: {}", doc.tarih)),
|
||
};
|
||
let date = parse_date(&date)
|
||
.map(format_date)
|
||
.ok_or_else(|| format!("TCMB tarihi geçersiz: {}", doc.tarih))?;
|
||
let rates = doc
|
||
.currencies
|
||
.into_iter()
|
||
.filter_map(|c| {
|
||
let unit: i64 = c
|
||
.unit
|
||
.as_deref()
|
||
.unwrap_or("1")
|
||
.trim()
|
||
.parse()
|
||
.ok()
|
||
.filter(|u| *u > 0)?;
|
||
let buying = decimal_e6(c.forex_buying.as_deref()?)?;
|
||
(buying > 0).then(|| (c.code.trim().to_uppercase(), buying / unit))
|
||
})
|
||
.collect();
|
||
Ok(Bulletin { date, rates })
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct XausSpot {
|
||
per_gram_usd: f64,
|
||
}
|
||
|
||
/// 1 gr saf altın, USD × 1e6
|
||
pub fn parse_gold(json: &str) -> Result<i64, String> {
|
||
let s: XausSpot =
|
||
serde_json::from_str(json).map_err(|e| format!("altın fiyatı okunamadı: {e}"))?;
|
||
// JSON sayıyı ondalıklı verir; kurda 6 basamak yeterli
|
||
let v = (s.per_gram_usd * 1_000_000.0).round();
|
||
if !(v.is_finite() && v > 0.0 && v < 1e15) {
|
||
return Err(format!("altın fiyatı geçersiz: {}", s.per_gram_usd));
|
||
}
|
||
Ok(v as i64)
|
||
}
|
||
|
||
/// Gram USD fiyatı ve USD/TL alış → altın kodlarının TL kurları
|
||
pub fn gold_rates(per_gram_usd_e6: i64, usd_try_e6: i64) -> Vec<(&'static str, i64)> {
|
||
let gram_try_e6 = i128::from(per_gram_usd_e6) * i128::from(usd_try_e6) / 1_000_000;
|
||
GOLD.iter()
|
||
.map(|(code, has_e6)| {
|
||
(
|
||
*code,
|
||
(gram_try_e6 * i128::from(*has_e6) / 1_000_000) as i64,
|
||
)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
pub const TCMB_SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||
<?xml-stylesheet type="text/xsl" href="isokur.xsl"?>
|
||
<Tarih_Date Tarih="12.11.2026" Date="11/12/2026" Bulten_No="2026/213" >
|
||
<Currency CrossOrder="0" Kod="USD" CurrencyCode="USD">
|
||
<Unit>1</Unit>
|
||
<Isim>ABD DOLARI</Isim>
|
||
<CurrencyName>US DOLLAR</CurrencyName>
|
||
<ForexBuying>50.1691</ForexBuying>
|
||
<ForexSelling>50.2595</ForexSelling>
|
||
<BanknoteBuying>50.1340</BanknoteBuying>
|
||
<BanknoteSelling>50.3349</BanknoteSelling>
|
||
<CrossRateUSD/>
|
||
<CrossRateOther/>
|
||
</Currency>
|
||
<Currency CrossOrder="9" Kod="JPY" CurrencyCode="JPY">
|
||
<Unit>100</Unit>
|
||
<Isim>JAPON YENİ</Isim>
|
||
<CurrencyName>JAPENESE YEN</CurrencyName>
|
||
<ForexBuying>32.4512</ForexBuying>
|
||
<ForexSelling>32.6661</ForexSelling>
|
||
</Currency>
|
||
<Currency CrossOrder="19" Kod="XDR" CurrencyCode="XDR">
|
||
<Unit>1</Unit>
|
||
<Isim>ÖZEL ÇEKME HAKKI (SDR)</Isim>
|
||
<CurrencyName>SPECIAL DRAWING RIGHT (SDR)</CurrencyName>
|
||
<ForexBuying>68.5521</ForexBuying>
|
||
<ForexSelling/>
|
||
</Currency>
|
||
<Currency CrossOrder="20" Kod="IRR" CurrencyCode="IRR">
|
||
<Unit>1</Unit>
|
||
<ForexBuying/>
|
||
</Currency>
|
||
</Tarih_Date>"#;
|
||
|
||
#[test]
|
||
fn decimal_without_floats() {
|
||
assert_eq!(decimal_e6("50.1691"), Some(50_169_100));
|
||
assert_eq!(decimal_e6("7"), Some(7_000_000));
|
||
assert_eq!(
|
||
decimal_e6("0.1234567"),
|
||
Some(123_457),
|
||
"7. basamak yuvarlanır"
|
||
);
|
||
assert_eq!(decimal_e6(""), None);
|
||
assert_eq!(decimal_e6("1,5"), None);
|
||
assert_eq!(decimal_e6("-2.0"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn tcmb_buying_rates_per_unit() {
|
||
let b = parse_tcmb(TCMB_SAMPLE).unwrap();
|
||
assert_eq!(b.date, "2026-11-12");
|
||
assert_eq!(
|
||
b.rates,
|
||
vec![
|
||
("USD".into(), 50_169_100),
|
||
("JPY".into(), 324_512), // 100 yen = 32,4512 TL
|
||
("XDR".into(), 68_552_100),
|
||
],
|
||
"alış kuru boş olan (IRR) atlanır"
|
||
);
|
||
assert!(parse_tcmb("<html>bakımda</html>").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn gold_from_usd() {
|
||
let g = parse_gold(
|
||
r#"{"xau":{"price":3874.1},"per_gram_usd":124.5552,"spot_usd_oz":3874.100098}"#,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(g, 124_555_200);
|
||
assert!(parse_gold(r#"{"per_gram_usd":0}"#).is_err());
|
||
assert!(parse_gold("{}").is_err());
|
||
|
||
let r = gold_rates(g, 50_169_100);
|
||
// 124,5552 $ × 50,1691 TL = 6.248,8222843 TL/gr
|
||
assert_eq!(r[0], ("XAU_GRAM", 6_248_822_284));
|
||
assert_eq!(r[1].0, "XAU_CEYREK");
|
||
assert_eq!(r[1].1, 6_248_822_284 * 1_603 / 1_000);
|
||
assert_eq!(r[4].1, 194_360_100_187, "ons ≈ 194.360,10 TL");
|
||
}
|
||
}
|