Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e54471e57 | |||
| 3f383c1a88 |
@@ -2277,7 +2277,12 @@ where
|
|||||||
(fill.quantity, fill.legs)
|
(fill.quantity, fill.legs)
|
||||||
} else {
|
} else {
|
||||||
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Sell);
|
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Sell);
|
||||||
if !self.price_satisfies_limit(
|
if let Some(reason) =
|
||||||
|
self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price)
|
||||||
|
{
|
||||||
|
partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason));
|
||||||
|
(0, Vec::new())
|
||||||
|
} else if !self.price_satisfies_limit(
|
||||||
OrderSide::Sell,
|
OrderSide::Sell,
|
||||||
execution_price,
|
execution_price,
|
||||||
limit_price,
|
limit_price,
|
||||||
@@ -3641,7 +3646,12 @@ where
|
|||||||
(fill.quantity, fill.legs)
|
(fill.quantity, fill.legs)
|
||||||
} else {
|
} else {
|
||||||
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy);
|
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy);
|
||||||
if !self.price_satisfies_limit(
|
if let Some(reason) =
|
||||||
|
self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price)
|
||||||
|
{
|
||||||
|
partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason));
|
||||||
|
(0, Vec::new())
|
||||||
|
} else if !self.price_satisfies_limit(
|
||||||
OrderSide::Buy,
|
OrderSide::Buy,
|
||||||
execution_price,
|
execution_price,
|
||||||
limit_price,
|
limit_price,
|
||||||
@@ -4275,6 +4285,26 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn execution_limit_rejection_reason(
|
||||||
|
&self,
|
||||||
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
|
side: OrderSide,
|
||||||
|
execution_price: f64,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
if !execution_price.is_finite() || execution_price <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match side {
|
||||||
|
OrderSide::Buy if snapshot.is_at_upper_limit_price(execution_price) => {
|
||||||
|
Some("open at or above upper limit")
|
||||||
|
}
|
||||||
|
OrderSide::Sell if snapshot.is_at_lower_limit_price(execution_price) => {
|
||||||
|
Some("open at or below lower limit")
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn execution_price_with_limit_slippage(
|
fn execution_price_with_limit_slippage(
|
||||||
&self,
|
&self,
|
||||||
execution_price: f64,
|
execution_price: f64,
|
||||||
@@ -4288,7 +4318,10 @@ where
|
|||||||
|
|
||||||
fn limit_order_can_remain_open(partial_reason: Option<&str>) -> bool {
|
fn limit_order_can_remain_open(partial_reason: Option<&str>) -> bool {
|
||||||
!partial_reason.is_some_and(|reason| {
|
!partial_reason.is_some_and(|reason| {
|
||||||
reason.contains("insufficient cash") || reason.contains("value budget")
|
reason.contains("insufficient cash")
|
||||||
|
|| reason.contains("value budget")
|
||||||
|
|| reason.contains("open at or above upper limit")
|
||||||
|
|| reason.contains("open at or below lower limit")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4426,6 +4459,9 @@ where
|
|||||||
let mut last_timestamp = None;
|
let mut last_timestamp = None;
|
||||||
let mut legs = Vec::new();
|
let mut legs = Vec::new();
|
||||||
let mut budget_block_reason = None;
|
let mut budget_block_reason = None;
|
||||||
|
let mut execution_block_reason = None;
|
||||||
|
let mut execution_block_timestamp = None;
|
||||||
|
let mut saw_non_blocked_execution_price = false;
|
||||||
let saw_quote_after_cursor = !eligible_quotes.is_empty();
|
let saw_quote_after_cursor = !eligible_quotes.is_empty();
|
||||||
|
|
||||||
for (quote_index, quote) in eligible_quotes.iter().enumerate() {
|
for (quote_index, quote) in eligible_quotes.iter().enumerate() {
|
||||||
@@ -4437,6 +4473,13 @@ where
|
|||||||
else {
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
||||||
|
{
|
||||||
|
execution_block_reason.get_or_insert(reason);
|
||||||
|
execution_block_timestamp = Some(quote.timestamp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
saw_non_blocked_execution_price = true;
|
||||||
if !self.price_satisfies_limit(
|
if !self.price_satisfies_limit(
|
||||||
side,
|
side,
|
||||||
quote_price,
|
quote_price,
|
||||||
@@ -4523,6 +4566,18 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
if filled_qty == 0 {
|
if filled_qty == 0 {
|
||||||
|
if let Some(reason) = execution_block_reason
|
||||||
|
&& !saw_non_blocked_execution_price
|
||||||
|
{
|
||||||
|
return Some(ExecutionFill {
|
||||||
|
quantity: 0,
|
||||||
|
next_cursor: execution_block_timestamp
|
||||||
|
.expect("blocked execution quote timestamp")
|
||||||
|
+ Duration::seconds(1),
|
||||||
|
legs: Vec::new(),
|
||||||
|
unfilled_reason: Some(reason),
|
||||||
|
});
|
||||||
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4619,7 +4674,9 @@ fn zero_fill_status_for_reason(reason: &str) -> OrderStatus {
|
|||||||
"tick no volume"
|
"tick no volume"
|
||||||
| "tick volume limit"
|
| "tick volume limit"
|
||||||
| "intraday quote liquidity exhausted"
|
| "intraday quote liquidity exhausted"
|
||||||
| "no execution quotes after start" => OrderStatus::Canceled,
|
| "no execution quotes after start"
|
||||||
|
| "open at or above upper limit"
|
||||||
|
| "open at or below lower limit" => OrderStatus::Canceled,
|
||||||
_ => OrderStatus::Rejected,
|
_ => OrderStatus::Rejected,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4629,7 +4686,9 @@ fn final_partial_fill_status(partial_reason: Option<&str>) -> OrderStatus {
|
|||||||
Some(reason)
|
Some(reason)
|
||||||
if reason.contains("market liquidity or volume limit")
|
if reason.contains("market liquidity or volume limit")
|
||||||
|| reason.contains("intraday quote liquidity exhausted")
|
|| reason.contains("intraday quote liquidity exhausted")
|
||||||
|| reason.contains("no execution quotes after start") =>
|
|| reason.contains("no execution quotes after start")
|
||||||
|
|| reason.contains("open at or above upper limit")
|
||||||
|
|| reason.contains("open at or below lower limit") =>
|
||||||
{
|
{
|
||||||
OrderStatus::Canceled
|
OrderStatus::Canceled
|
||||||
}
|
}
|
||||||
@@ -4658,8 +4717,54 @@ fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{BrokerSimulator, MatchingType};
|
use super::{BrokerSimulator, MatchingType};
|
||||||
use crate::cost::ChinaAShareCostModel;
|
use crate::cost::ChinaAShareCostModel;
|
||||||
|
use crate::data::{DailyMarketSnapshot, IntradayExecutionQuote, PriceField};
|
||||||
|
use crate::events::OrderSide;
|
||||||
use crate::rules::ChinaEquityRuleHooks;
|
use crate::rules::ChinaEquityRuleHooks;
|
||||||
|
|
||||||
|
fn limit_test_snapshot() -> DailyMarketSnapshot {
|
||||||
|
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
|
DailyMarketSnapshot {
|
||||||
|
date,
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
timestamp: Some("2025-01-02 09:33:00".to_string()),
|
||||||
|
day_open: 10.0,
|
||||||
|
open: 10.0,
|
||||||
|
high: 10.5,
|
||||||
|
low: 9.5,
|
||||||
|
close: 10.0,
|
||||||
|
last_price: 10.0,
|
||||||
|
bid1: 10.0,
|
||||||
|
ask1: 10.0,
|
||||||
|
prev_close: 10.0,
|
||||||
|
volume: 1_000_000,
|
||||||
|
tick_volume: 10_000,
|
||||||
|
bid1_volume: 1_000,
|
||||||
|
ask1_volume: 1_000,
|
||||||
|
trading_phase: Some("continuous".to_string()),
|
||||||
|
paused: false,
|
||||||
|
upper_limit: 11.0,
|
||||||
|
lower_limit: 9.0,
|
||||||
|
price_tick: 0.01,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn limit_test_quote(last_price: f64, bid1: f64, ask1: f64) -> IntradayExecutionQuote {
|
||||||
|
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
|
IntradayExecutionQuote {
|
||||||
|
date,
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
|
||||||
|
last_price,
|
||||||
|
bid1,
|
||||||
|
ask1,
|
||||||
|
bid1_volume: 1_000,
|
||||||
|
ask1_volume: 1_000,
|
||||||
|
volume_delta: 1_000,
|
||||||
|
amount_delta: last_price * 1_000.0,
|
||||||
|
trading_phase: Some("continuous".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn next_tick_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() {
|
fn next_tick_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() {
|
||||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||||
@@ -4706,4 +4811,78 @@ mod tests {
|
|||||||
Some(cursor + chrono::Duration::minutes(1))
|
Some(cursor + chrono::Duration::minutes(1))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn intraday_execution_rejects_buy_at_upper_limit_price() {
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Last,
|
||||||
|
)
|
||||||
|
.with_volume_limit(false)
|
||||||
|
.with_liquidity_limit(false)
|
||||||
|
.with_inactive_limit(false);
|
||||||
|
let snapshot = limit_test_snapshot();
|
||||||
|
let quote = limit_test_quote(11.0, 10.99, 11.0);
|
||||||
|
let start = quote.timestamp;
|
||||||
|
|
||||||
|
let fill = broker
|
||||||
|
.select_execution_fill(
|
||||||
|
&snapshot,
|
||||||
|
&[quote],
|
||||||
|
OrderSide::Buy,
|
||||||
|
MatchingType::NextTickLast,
|
||||||
|
Some(start),
|
||||||
|
None,
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("zero fill with rejection reason");
|
||||||
|
|
||||||
|
assert_eq!(fill.quantity, 0);
|
||||||
|
assert_eq!(fill.unfilled_reason, Some("open at or above upper limit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn intraday_execution_rejects_sell_at_lower_limit_price() {
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Last,
|
||||||
|
)
|
||||||
|
.with_volume_limit(false)
|
||||||
|
.with_liquidity_limit(false)
|
||||||
|
.with_inactive_limit(false);
|
||||||
|
let snapshot = limit_test_snapshot();
|
||||||
|
let quote = limit_test_quote(9.0, 9.0, 9.01);
|
||||||
|
let start = quote.timestamp;
|
||||||
|
|
||||||
|
let fill = broker
|
||||||
|
.select_execution_fill(
|
||||||
|
&snapshot,
|
||||||
|
&[quote],
|
||||||
|
OrderSide::Sell,
|
||||||
|
MatchingType::NextTickLast,
|
||||||
|
Some(start),
|
||||||
|
None,
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("zero fill with rejection reason");
|
||||||
|
|
||||||
|
assert_eq!(fill.quantity, 0);
|
||||||
|
assert_eq!(fill.unfilled_reason, Some("open at or below lower limit"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -452,11 +452,11 @@ struct SymbolPriceSeries {
|
|||||||
closes: Vec<f64>,
|
closes: Vec<f64>,
|
||||||
prev_closes: Vec<f64>,
|
prev_closes: Vec<f64>,
|
||||||
last_prices: Vec<f64>,
|
last_prices: Vec<f64>,
|
||||||
|
paused: Vec<bool>,
|
||||||
open_prefix: Vec<f64>,
|
open_prefix: Vec<f64>,
|
||||||
close_prefix: Vec<f64>,
|
close_prefix: Vec<f64>,
|
||||||
prev_close_prefix: Vec<f64>,
|
prev_close_prefix: Vec<f64>,
|
||||||
last_prefix: Vec<f64>,
|
last_prefix: Vec<f64>,
|
||||||
volume_prefix: Vec<f64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SymbolPriceSeries {
|
impl SymbolPriceSeries {
|
||||||
@@ -469,15 +469,11 @@ impl SymbolPriceSeries {
|
|||||||
let closes = sorted.iter().map(|row| row.close).collect::<Vec<_>>();
|
let closes = sorted.iter().map(|row| row.close).collect::<Vec<_>>();
|
||||||
let prev_closes = sorted.iter().map(|row| row.prev_close).collect::<Vec<_>>();
|
let prev_closes = sorted.iter().map(|row| row.prev_close).collect::<Vec<_>>();
|
||||||
let last_prices = sorted.iter().map(|row| row.last_price).collect::<Vec<_>>();
|
let last_prices = sorted.iter().map(|row| row.last_price).collect::<Vec<_>>();
|
||||||
let volumes = sorted
|
let paused = sorted.iter().map(|row| row.paused).collect::<Vec<_>>();
|
||||||
.iter()
|
|
||||||
.map(|row| row.volume as f64)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let open_prefix = prefix_sums(&opens);
|
let open_prefix = prefix_sums(&opens);
|
||||||
let close_prefix = prefix_sums(&closes);
|
let close_prefix = prefix_sums(&closes);
|
||||||
let prev_close_prefix = prefix_sums(&prev_closes);
|
let prev_close_prefix = prefix_sums(&prev_closes);
|
||||||
let last_prefix = prefix_sums(&last_prices);
|
let last_prefix = prefix_sums(&last_prices);
|
||||||
let volume_prefix = prefix_sums(&volumes);
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
snapshots: sorted,
|
snapshots: sorted,
|
||||||
@@ -486,11 +482,11 @@ impl SymbolPriceSeries {
|
|||||||
closes,
|
closes,
|
||||||
prev_closes,
|
prev_closes,
|
||||||
last_prices,
|
last_prices,
|
||||||
|
paused,
|
||||||
open_prefix,
|
open_prefix,
|
||||||
close_prefix,
|
close_prefix,
|
||||||
prev_close_prefix,
|
prev_close_prefix,
|
||||||
last_prefix,
|
last_prefix,
|
||||||
volume_prefix,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,15 +583,11 @@ impl SymbolPriceSeries {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
|
fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
|
||||||
if lookback == 0 {
|
let values = self.decision_volume_values(date, lookback)?;
|
||||||
|
if values.len() < lookback {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let end = self.previous_completed_end_index(date)?;
|
let sum = values.iter().sum::<f64>();
|
||||||
if end < lookback {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let start = end - lookback;
|
|
||||||
let sum = self.volume_prefix[end] - self.volume_prefix[start];
|
|
||||||
Some(sum / lookback as f64)
|
Some(sum / lookback as f64)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,11 +596,11 @@ impl SymbolPriceSeries {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let end = self.end_index(date)?;
|
let end = self.end_index(date)?;
|
||||||
if end < lookback {
|
let values = self.trailing_unpaused_volumes(end, lookback)?;
|
||||||
|
if values.len() < lookback {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let start = end - lookback;
|
let sum = values.iter().sum::<f64>();
|
||||||
let sum = self.volume_prefix[end] - self.volume_prefix[start];
|
|
||||||
Some(sum / lookback as f64)
|
Some(sum / lookback as f64)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,16 +609,33 @@ impl SymbolPriceSeries {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let end = self.previous_completed_end_index(date)?;
|
let end = self.previous_completed_end_index(date)?;
|
||||||
if end < lookback {
|
let values = self.trailing_unpaused_volumes(end, lookback)?;
|
||||||
|
if values.len() < lookback {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let start = end - lookback;
|
Some(values)
|
||||||
Some(
|
}
|
||||||
self.snapshots[start..end]
|
|
||||||
.iter()
|
fn trailing_unpaused_volumes(&self, end: usize, lookback: usize) -> Option<Vec<f64>> {
|
||||||
.map(|snapshot| snapshot.volume as f64)
|
if lookback == 0 || end == 0 {
|
||||||
.collect(),
|
return None;
|
||||||
)
|
}
|
||||||
|
let mut values = Vec::with_capacity(lookback);
|
||||||
|
for idx in (0..end).rev() {
|
||||||
|
if self.paused.get(idx).copied().unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
values.push(self.snapshots[idx].volume as f64);
|
||||||
|
if values.len() == lookback {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if values.len() < lookback {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
values.reverse();
|
||||||
|
Some(values)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn end_index(&self, date: NaiveDate) -> Option<usize> {
|
fn end_index(&self, date: NaiveDate) -> Option<usize> {
|
||||||
@@ -3385,6 +3394,33 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decision_volume_average_skips_paused_days_before_counting_window() {
|
||||||
|
let mut paused = market_row("2025-01-03", 11.0, 0);
|
||||||
|
paused.paused = true;
|
||||||
|
let series = SymbolPriceSeries::new(&[
|
||||||
|
market_row("2025-01-02", 10.0, 100),
|
||||||
|
paused,
|
||||||
|
market_row("2025-01-06", 12.0, 300),
|
||||||
|
market_row("2025-01-07", 13.0, 10_000),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
series.decision_volume_moving_average(
|
||||||
|
NaiveDate::parse_from_str("2025-01-07", "%Y-%m-%d").unwrap(),
|
||||||
|
2
|
||||||
|
),
|
||||||
|
Some(200.0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
series.decision_volume_moving_average(
|
||||||
|
NaiveDate::parse_from_str("2025-01-07", "%Y-%m-%d").unwrap(),
|
||||||
|
3
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reads_mixed_numeric_and_text_extra_factors_from_quoted_csv_json() {
|
fn reads_mixed_numeric_and_text_extra_factors_from_quoted_csv_json() {
|
||||||
let path = temp_csv_path("mixed_factor_maps");
|
let path = temp_csv_path("mixed_factor_maps");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ use crate::data::{DataSet, DataSetError, PriceField};
|
|||||||
pub struct PositionLot {
|
pub struct PositionLot {
|
||||||
pub acquired_date: NaiveDate,
|
pub acquired_date: NaiveDate,
|
||||||
pub quantity: u32,
|
pub quantity: u32,
|
||||||
|
pub entry_price: f64,
|
||||||
pub price: f64,
|
pub price: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ impl Position {
|
|||||||
self.lots.push(PositionLot {
|
self.lots.push(PositionLot {
|
||||||
acquired_date: date,
|
acquired_date: date,
|
||||||
quantity,
|
quantity,
|
||||||
|
entry_price: price,
|
||||||
price,
|
price,
|
||||||
});
|
});
|
||||||
self.quantity += quantity;
|
self.quantity += quantity;
|
||||||
@@ -230,13 +232,28 @@ impl Position {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn holding_return(&self, price: f64) -> Option<f64> {
|
pub fn holding_return(&self, price: f64) -> Option<f64> {
|
||||||
if self.quantity == 0 || self.average_cost <= 0.0 {
|
let Some(avg_price) = self.average_entry_price() else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if avg_price <= 0.0 {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some((price / self.average_cost) - 1.0)
|
Some((price / avg_price) - 1.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn average_entry_price(&self) -> Option<f64> {
|
||||||
|
if self.quantity == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let total = self
|
||||||
|
.lots
|
||||||
|
.iter()
|
||||||
|
.map(|lot| lot.entry_price * lot.quantity as f64)
|
||||||
|
.sum::<f64>();
|
||||||
|
Some(total / self.quantity as f64)
|
||||||
|
}
|
||||||
|
|
||||||
fn recalculate_average_cost(&mut self) {
|
fn recalculate_average_cost(&mut self) {
|
||||||
if self.quantity == 0 {
|
if self.quantity == 0 {
|
||||||
self.average_cost = 0.0;
|
self.average_cost = 0.0;
|
||||||
@@ -258,6 +275,7 @@ impl Position {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for lot in &mut self.lots {
|
for lot in &mut self.lots {
|
||||||
|
lot.entry_price -= dividend_per_share;
|
||||||
lot.price -= dividend_per_share;
|
lot.price -= dividend_per_share;
|
||||||
}
|
}
|
||||||
self.average_cost -= dividend_per_share;
|
self.average_cost -= dividend_per_share;
|
||||||
@@ -280,6 +298,7 @@ impl Position {
|
|||||||
.map(|lot| PositionLot {
|
.map(|lot| PositionLot {
|
||||||
acquired_date: lot.acquired_date,
|
acquired_date: lot.acquired_date,
|
||||||
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
|
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
|
||||||
|
entry_price: lot.entry_price / ratio,
|
||||||
price: lot.price / ratio,
|
price: lot.price / ratio,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -759,6 +778,7 @@ impl PortfolioState {
|
|||||||
.map(|lot| PositionLot {
|
.map(|lot| PositionLot {
|
||||||
acquired_date: lot.acquired_date,
|
acquired_date: lot.acquired_date,
|
||||||
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
|
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
|
||||||
|
entry_price: lot.entry_price / ratio,
|
||||||
price: lot.price / ratio,
|
price: lot.price / ratio,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -855,6 +875,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strategy_entry_price_excludes_buy_commission_cost_basis() {
|
||||||
|
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||||
|
let mut position = Position::new("600561.SH");
|
||||||
|
position.buy(date, 22_200, 5.66);
|
||||||
|
position.record_buy_trade_cost(22_200, 100.0);
|
||||||
|
|
||||||
|
assert!(position.average_cost > 5.66);
|
||||||
|
assert!((position.average_entry_price().unwrap() - 5.66).abs() < 1e-12);
|
||||||
|
assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn portfolio_tracks_dividend_receivable_and_day_pnl() {
|
fn portfolio_tracks_dividend_receivable_and_day_pnl() {
|
||||||
let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||||
|
|||||||
@@ -2388,11 +2388,6 @@ impl OmniMicroCapStrategy {
|
|||||||
{
|
{
|
||||||
return Ok(Some("upper_limit".to_string()));
|
return Ok(Some("upper_limit".to_string()));
|
||||||
}
|
}
|
||||||
if market.is_at_lower_limit_price(market.day_open)
|
|
||||||
|| market.is_at_lower_limit_price(market.sell_price(PriceField::Last))
|
|
||||||
{
|
|
||||||
return Ok(Some("lower_limit".to_string()));
|
|
||||||
}
|
|
||||||
if market.day_open <= 1.0 {
|
if market.day_open <= 1.0 {
|
||||||
return Ok(Some("one_yuan".to_string()));
|
return Ok(Some("one_yuan".to_string()));
|
||||||
}
|
}
|
||||||
@@ -2744,8 +2739,7 @@ impl Strategy for OmniMicroCapStrategy {
|
|||||||
let stop_hit = current_price
|
let stop_hit = current_price
|
||||||
<= position.average_cost * self.config.stop_loss_ratio
|
<= position.average_cost * self.config.stop_loss_ratio
|
||||||
+ self.stop_loss_tolerance(market);
|
+ self.stop_loss_tolerance(market);
|
||||||
let profit_hit = !market.is_at_upper_limit_price(current_price)
|
let profit_hit = current_price / position.average_cost > self.config.take_profit_ratio;
|
||||||
&& current_price / position.average_cost > self.config.take_profit_ratio;
|
|
||||||
let can_sell = self.can_sell_position(ctx, date, &position.symbol);
|
let can_sell = self.can_sell_position(ctx, date, &position.symbol);
|
||||||
if stop_hit || profit_hit {
|
if stop_hit || profit_hit {
|
||||||
let sell_reason = if stop_hit {
|
let sell_reason = if stop_hit {
|
||||||
|
|||||||
Reference in New Issue
Block a user