mirror of
https://github.com/microsoft/qlib.git
synced 2026-07-15 00:36:55 +08:00
Merge pull request #650 from microsoft/backtest_improve
Improve the backtest design and APIs
This commit is contained in:
17
CHANGES.rst
17
CHANGES.rst
@@ -159,6 +159,21 @@ Version 0.5.0
|
|||||||
- Add baselines
|
- Add baselines
|
||||||
- public data crawler
|
- public data crawler
|
||||||
|
|
||||||
Version greater than Version 0.5.0
|
|
||||||
|
Version 0.8.0
|
||||||
|
--------------------
|
||||||
|
- The backtest is greatly refactored.
|
||||||
|
- Nested decision execution framework is supported
|
||||||
|
- There are lots of changes for daily trading, it is hard to list all of them. But a few important changes could be noticed
|
||||||
|
- The trading limitation is more accurate;
|
||||||
|
- In `previous version <https://github.com/microsoft/qlib/blob/v0.7.2/qlib/contrib/backtest/exchange.py#L160>`_, longing and shorting actions share the same action.
|
||||||
|
- In `current verison <https://github.com/microsoft/qlib/blob/7c31012b507a3823117bddcc693fc64899460b2a/qlib/backtest/exchange.py#L304>`_, the trading limitation is different between loging and shorting action.
|
||||||
|
- The constant is different when calculating annualized metrics.
|
||||||
|
- `Current version <https://github.com/microsoft/qlib/blob/7c31012b507a3823117bddcc693fc64899460b2a/qlib/contrib/evaluate.py#L42>`_ uses more accurate constant than `previous version <https://github.com/microsoft/qlib/blob/v0.7.2/qlib/contrib/evaluate.py#L22>`_
|
||||||
|
- `A new version <https://github.com/microsoft/qlib/blob/7c31012b507a3823117bddcc693fc64899460b2a/qlib/tests/data.py#L17>`_ of data is released. Due to the unstability of Yahoo data source, the data may be different after downloading data again.
|
||||||
|
- Users could chec kout the backtesting results between `Current version <https://github.com/microsoft/qlib/tree/7c31012b507a3823117bddcc693fc64899460b2a/examples/benchmarks>`_ and `previous version <https://github.com/microsoft/qlib/tree/v0.7.2/examples/benchmarks>`_
|
||||||
|
|
||||||
|
|
||||||
|
Other Versions
|
||||||
----------------------------------
|
----------------------------------
|
||||||
Please refer to `Github release Notes <https://github.com/microsoft/qlib/releases>`_
|
Please refer to `Github release Notes <https://github.com/microsoft/qlib/releases>`_
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ Below is a typical config file of ``qrun``.
|
|||||||
kwargs:
|
kwargs:
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
|
signal:
|
||||||
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
backtest:
|
backtest:
|
||||||
limit_threshold: 0.095
|
limit_threshold: 0.095
|
||||||
account: 100000000
|
account: 100000000
|
||||||
@@ -240,6 +243,9 @@ The following script is the configuration of `backtest` and the `strategy` used
|
|||||||
kwargs:
|
kwargs:
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
|
signal:
|
||||||
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
backtest:
|
backtest:
|
||||||
limit_threshold: 0.095
|
limit_threshold: 0.095
|
||||||
account: 100000000
|
account: 100000000
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
@@ -86,4 +87,4 @@ task:
|
|||||||
- class: PortAnaRecord
|
- class: PortAnaRecord
|
||||||
module_path: qlib.workflow.record_temp
|
module_path: qlib.workflow.record_temp
|
||||||
kwargs:
|
kwargs:
|
||||||
config: *port_analysis_config
|
config: *port_analysis_config
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
@@ -100,4 +101,4 @@ task:
|
|||||||
- class: PortAnaRecord
|
- class: PortAnaRecord
|
||||||
module_path: qlib.workflow.record_temp
|
module_path: qlib.workflow.record_temp
|
||||||
kwargs:
|
kwargs:
|
||||||
config: *port_analysis_config
|
config: *port_analysis_config
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
@@ -94,4 +95,4 @@ task:
|
|||||||
- class: PortAnaRecord
|
- class: PortAnaRecord
|
||||||
module_path: qlib.workflow.record_temp
|
module_path: qlib.workflow.record_temp
|
||||||
kwargs:
|
kwargs:
|
||||||
config: *port_analysis_config
|
config: *port_analysis_config
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
@@ -85,4 +86,4 @@ task:
|
|||||||
- class: PortAnaRecord
|
- class: PortAnaRecord
|
||||||
module_path: qlib.workflow.record_temp
|
module_path: qlib.workflow.record_temp
|
||||||
kwargs:
|
kwargs:
|
||||||
config: *port_analysis_config
|
config: *port_analysis_config
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
@@ -85,4 +86,4 @@ task:
|
|||||||
- class: PortAnaRecord
|
- class: PortAnaRecord
|
||||||
module_path: qlib.workflow.record_temp
|
module_path: qlib.workflow.record_temp
|
||||||
kwargs:
|
kwargs:
|
||||||
config: *port_analysis_config
|
config: *port_analysis_config
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
model: <MODEL>
|
||||||
dataset: <DATASET>
|
dataset: <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
kwargs:
|
kwargs:
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
|
signal:
|
||||||
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
backtest:
|
backtest:
|
||||||
verbose: False
|
verbose: False
|
||||||
limit_threshold: 0.095
|
limit_threshold: 0.095
|
||||||
@@ -80,4 +83,4 @@ task:
|
|||||||
- class: PortAnaRecord
|
- class: PortAnaRecord
|
||||||
module_path: qlib.workflow.record_temp
|
module_path: qlib.workflow.record_temp
|
||||||
kwargs:
|
kwargs:
|
||||||
config: *port_analysis_config
|
config: *port_analysis_config
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
@@ -76,4 +77,4 @@ task:
|
|||||||
- class: PortAnaRecord
|
- class: PortAnaRecord
|
||||||
module_path: qlib.workflow.record_temp
|
module_path: qlib.workflow.record_temp
|
||||||
kwargs:
|
kwargs:
|
||||||
config: *port_analysis_config
|
config: *port_analysis_config
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
@@ -98,4 +99,4 @@ task:
|
|||||||
- class: PortAnaRecord
|
- class: PortAnaRecord
|
||||||
module_path: qlib.workflow.record_temp
|
module_path: qlib.workflow.record_temp
|
||||||
kwargs:
|
kwargs:
|
||||||
config: *port_analysis_config
|
config: *port_analysis_config
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
@@ -85,4 +86,4 @@ task:
|
|||||||
- class: PortAnaRecord
|
- class: PortAnaRecord
|
||||||
module_path: qlib.workflow.record_temp
|
module_path: qlib.workflow.record_temp
|
||||||
kwargs:
|
kwargs:
|
||||||
config: *port_analysis_config
|
config: *port_analysis_config
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ The numbers shown below demonstrate the performance of the entire `workflow` of
|
|||||||
>
|
>
|
||||||
> In the new version of qlib, the default dataset is **v2**. Since the data is collected from the YahooFinance API (which is not very stable), the results of *v2* and *v1* may differ -->
|
> In the new version of qlib, the default dataset is **v2**. Since the data is collected from the YahooFinance API (which is not very stable), the results of *v2* and *v1* may differ -->
|
||||||
|
|
||||||
|
> NOTE:
|
||||||
|
> The backtest start from 0.8.0 is quite different from previous version. Please check out the changelog for the difference.
|
||||||
|
|
||||||
|
|
||||||
## Alpha158 dataset
|
## Alpha158 dataset
|
||||||
|
|
||||||
| Model Name | Dataset | IC | ICIR | Rank IC | Rank ICIR | Annualized Return | Information Ratio | Max Drawdown |
|
| Model Name | Dataset | IC | ICIR | Rank IC | Rank ICIR | Annualized Return | Information Ratio | Max Drawdown |
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
@@ -94,4 +95,4 @@ task:
|
|||||||
- class: PortAnaRecord
|
- class: PortAnaRecord
|
||||||
module_path: qlib.workflow.record_temp
|
module_path: qlib.workflow.record_temp
|
||||||
kwargs:
|
kwargs:
|
||||||
config: *port_analysis_config
|
config: *port_analysis_config
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -57,8 +57,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ port_analysis_config: &port_analysis_config
|
|||||||
class: TopkDropoutStrategy
|
class: TopkDropoutStrategy
|
||||||
module_path: qlib.contrib.strategy
|
module_path: qlib.contrib.strategy
|
||||||
kwargs:
|
kwargs:
|
||||||
model: <MODEL>
|
signal:
|
||||||
dataset: <DATASET>
|
- <MODEL>
|
||||||
|
- <DATASET>
|
||||||
topk: 50
|
topk: 50
|
||||||
n_drop: 5
|
n_drop: 5
|
||||||
backtest:
|
backtest:
|
||||||
|
|||||||
@@ -151,10 +151,9 @@ class NestedDecisionExecutionWorkflow:
|
|||||||
self._train_model(model, dataset)
|
self._train_model(model, dataset)
|
||||||
strategy_config = {
|
strategy_config = {
|
||||||
"class": "TopkDropoutStrategy",
|
"class": "TopkDropoutStrategy",
|
||||||
"module_path": "qlib.contrib.strategy.model_strategy",
|
"module_path": "qlib.contrib.strategy.signal_strategy",
|
||||||
"kwargs": {
|
"kwargs": {
|
||||||
"model": model,
|
"signal": (model, dataset),
|
||||||
"dataset": dataset,
|
|
||||||
"topk": 50,
|
"topk": 50,
|
||||||
"n_drop": 5,
|
"n_drop": 5,
|
||||||
},
|
},
|
||||||
@@ -189,10 +188,9 @@ class NestedDecisionExecutionWorkflow:
|
|||||||
backtest_config["benchmark"] = self.benchmark
|
backtest_config["benchmark"] = self.benchmark
|
||||||
strategy_config = {
|
strategy_config = {
|
||||||
"class": "TopkDropoutStrategy",
|
"class": "TopkDropoutStrategy",
|
||||||
"module_path": "qlib.contrib.strategy.model_strategy",
|
"module_path": "qlib.contrib.strategy.signal_strategy",
|
||||||
"kwargs": {
|
"kwargs": {
|
||||||
"model": model,
|
"signal": (model, dataset),
|
||||||
"dataset": dataset,
|
|
||||||
"topk": 50,
|
"topk": 50,
|
||||||
"n_drop": 5,
|
"n_drop": 5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,10 +31,9 @@ if __name__ == "__main__":
|
|||||||
},
|
},
|
||||||
"strategy": {
|
"strategy": {
|
||||||
"class": "TopkDropoutStrategy",
|
"class": "TopkDropoutStrategy",
|
||||||
"module_path": "qlib.contrib.strategy.model_strategy",
|
"module_path": "qlib.contrib.strategy.signal_strategy",
|
||||||
"kwargs": {
|
"kwargs": {
|
||||||
"model": model,
|
"signal": (model, dataset),
|
||||||
"dataset": dataset,
|
|
||||||
"topk": 50,
|
"topk": 50,
|
||||||
"n_drop": 5,
|
"n_drop": 5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -152,8 +152,11 @@ def init_from_yaml_conf(conf_path, **kwargs):
|
|||||||
:param conf_path: A path to the qlib config in yml format
|
:param conf_path: A path to the qlib config in yml format
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with open(conf_path) as f:
|
if conf_path is None:
|
||||||
config = yaml.safe_load(f)
|
config = {}
|
||||||
|
else:
|
||||||
|
with open(conf_path) as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
config.update(kwargs)
|
config.update(kwargs)
|
||||||
default_conf = config.pop("default_conf", "client")
|
default_conf = config.pop("default_conf", "client")
|
||||||
init(default_conf, **config)
|
init(default_conf, **config)
|
||||||
@@ -216,7 +219,7 @@ def auto_init(**kwargs):
|
|||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
|
||||||
conf_type: ref
|
conf_type: ref
|
||||||
qlib_cfg: '<shared_yaml_config_path>'
|
qlib_cfg: '<shared_yaml_config_path>' # this could be null reference no config from other files
|
||||||
# following configs in `qlib_cfg_update` is project=specific
|
# following configs in `qlib_cfg_update` is project=specific
|
||||||
qlib_cfg_update:
|
qlib_cfg_update:
|
||||||
exp_manager:
|
exp_manager:
|
||||||
@@ -246,6 +249,7 @@ def auto_init(**kwargs):
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
init(**kwargs)
|
init(**kwargs)
|
||||||
else:
|
else:
|
||||||
|
logger = get_module_logger("Initialization")
|
||||||
conf_pp = pp / "config.yaml"
|
conf_pp = pp / "config.yaml"
|
||||||
with conf_pp.open() as f:
|
with conf_pp.open() as f:
|
||||||
conf = yaml.safe_load(f)
|
conf = yaml.safe_load(f)
|
||||||
@@ -259,8 +263,14 @@ def auto_init(**kwargs):
|
|||||||
# - There is a shared configure file and you don't want to edit it inplace.
|
# - There is a shared configure file and you don't want to edit it inplace.
|
||||||
# - The shared configure may be updated later and you don't want to copy it.
|
# - The shared configure may be updated later and you don't want to copy it.
|
||||||
# - You have some customized config.
|
# - You have some customized config.
|
||||||
qlib_conf_path = conf["qlib_cfg"]
|
qlib_conf_path = conf.get("qlib_cfg", None)
|
||||||
qlib_conf_update = conf.get("qlib_cfg_update")
|
|
||||||
init_from_yaml_conf(qlib_conf_path, **qlib_conf_update, **kwargs)
|
# merge the arguments
|
||||||
logger = get_module_logger("Initialization")
|
qlib_conf_update = conf.get("qlib_cfg_update", {})
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
if k in qlib_conf_update:
|
||||||
|
logger.warning(f"`qlib_conf_update` from conf_pp is override by `kwargs` on key '{k}'")
|
||||||
|
qlib_conf_update.update(kwargs)
|
||||||
|
|
||||||
|
init_from_yaml_conf(qlib_conf_path, **qlib_conf_update)
|
||||||
logger.info(f"Auto load project config: {conf_pp}")
|
logger.info(f"Auto load project config: {conf_pp}")
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class Exchange:
|
|||||||
open_cost=0.0015,
|
open_cost=0.0015,
|
||||||
close_cost=0.0025,
|
close_cost=0.0025,
|
||||||
min_cost=5,
|
min_cost=5,
|
||||||
|
impact_cost=0.0,
|
||||||
extra_quote=None,
|
extra_quote=None,
|
||||||
quote_cls=NumpyQuote,
|
quote_cls=NumpyQuote,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
@@ -95,6 +96,7 @@ class Exchange:
|
|||||||
**NOTE**: `trade_unit` is included in the `kwargs`. It is necessary because we must
|
**NOTE**: `trade_unit` is included in the `kwargs`. It is necessary because we must
|
||||||
distinguish `not set` and `disable trade_unit`
|
distinguish `not set` and `disable trade_unit`
|
||||||
:param min_cost: min cost, default 5
|
:param min_cost: min cost, default 5
|
||||||
|
:param impact_cost: market impact cost rate (a.k.a. slippage). A recommended value is 0.1.
|
||||||
:param extra_quote: pandas, dataframe consists of
|
:param extra_quote: pandas, dataframe consists of
|
||||||
columns: like ['$vwap', '$close', '$volume', '$factor', 'limit_sell', 'limit_buy'].
|
columns: like ['$vwap', '$close', '$volume', '$factor', 'limit_sell', 'limit_buy'].
|
||||||
The limit indicates that the etf is tradable on a specific day.
|
The limit indicates that the etf is tradable on a specific day.
|
||||||
@@ -164,9 +166,12 @@ class Exchange:
|
|||||||
all_fields = list(all_fields | set(subscribe_fields))
|
all_fields = list(all_fields | set(subscribe_fields))
|
||||||
|
|
||||||
self.all_fields = all_fields
|
self.all_fields = all_fields
|
||||||
|
|
||||||
self.open_cost = open_cost
|
self.open_cost = open_cost
|
||||||
self.close_cost = close_cost
|
self.close_cost = close_cost
|
||||||
self.min_cost = min_cost
|
self.min_cost = min_cost
|
||||||
|
self.impact_cost = impact_cost
|
||||||
|
|
||||||
self.limit_threshold: Union[Tuple[str, str], float, None] = limit_threshold
|
self.limit_threshold: Union[Tuple[str, str], float, None] = limit_threshold
|
||||||
self.volume_threshold = volume_threshold
|
self.volume_threshold = volume_threshold
|
||||||
self.extra_quote = extra_quote
|
self.extra_quote = extra_quote
|
||||||
@@ -685,12 +690,14 @@ class Exchange:
|
|||||||
f"Order clipped due to volume limitation: {order}, {[(vol, rule) for vol, rule in zip(vol_limit_num, vol_limit)]}"
|
f"Order clipped due to volume limitation: {order}, {[(vol, rule) for vol, rule in zip(vol_limit_num, vol_limit)]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_buy_amount_by_cash_limit(self, trade_price, cash):
|
def _get_buy_amount_by_cash_limit(self, trade_price, cash, cost_ratio):
|
||||||
"""return the real order amount after cash limit for buying.
|
"""return the real order amount after cash limit for buying.
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
trade_price : float
|
trade_price : float
|
||||||
position : cash
|
position : cash
|
||||||
|
cost_ratio : float
|
||||||
|
|
||||||
Return
|
Return
|
||||||
----------
|
----------
|
||||||
float
|
float
|
||||||
@@ -699,10 +706,10 @@ class Exchange:
|
|||||||
max_trade_amount = 0
|
max_trade_amount = 0
|
||||||
if cash >= self.min_cost:
|
if cash >= self.min_cost:
|
||||||
# critical_price means the stock transaction price when the service fee is equal to min_cost.
|
# critical_price means the stock transaction price when the service fee is equal to min_cost.
|
||||||
critical_price = self.min_cost / self.open_cost + self.min_cost
|
critical_price = self.min_cost / cost_ratio + self.min_cost
|
||||||
if cash >= critical_price:
|
if cash >= critical_price:
|
||||||
# the service fee is equal to open_cost * trade_amount
|
# the service fee is equal to cost_ratio * trade_amount
|
||||||
max_trade_amount = cash / (1 + self.open_cost) / trade_price
|
max_trade_amount = cash / (1 + cost_ratio) / trade_price
|
||||||
else:
|
else:
|
||||||
# the service fee is equal to min_cost
|
# the service fee is equal to min_cost
|
||||||
max_trade_amount = (cash - self.min_cost) / trade_price
|
max_trade_amount = (cash - self.min_cost) / trade_price
|
||||||
@@ -718,6 +725,7 @@ class Exchange:
|
|||||||
:return: trade_price, trade_val, trade_cost
|
:return: trade_price, trade_val, trade_cost
|
||||||
"""
|
"""
|
||||||
trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time, direction=order.direction)
|
trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time, direction=order.direction)
|
||||||
|
total_trade_val = self.get_volume(order.stock_id, order.start_time, order.end_time) * trade_price
|
||||||
order.factor = self.get_factor(order.stock_id, order.start_time, order.end_time)
|
order.factor = self.get_factor(order.stock_id, order.start_time, order.end_time)
|
||||||
order.deal_amount = order.amount # set to full amount and clip it step by step
|
order.deal_amount = order.amount # set to full amount and clip it step by step
|
||||||
# Clipping amount first
|
# Clipping amount first
|
||||||
@@ -726,8 +734,12 @@ class Exchange:
|
|||||||
# - It simulates that the large order is submitted, but partial is dealt regardless of rounding by trading unit.
|
# - It simulates that the large order is submitted, but partial is dealt regardless of rounding by trading unit.
|
||||||
self._clip_amount_by_volume(order, dealt_order_amount)
|
self._clip_amount_by_volume(order, dealt_order_amount)
|
||||||
|
|
||||||
|
# TODO: the adjusted cost ratio can be overestimated as deal_amount will be clipped in the next steps
|
||||||
|
trade_val = order.deal_amount * trade_price
|
||||||
|
adj_cost_ratio = self.impact_cost * (trade_val / total_trade_val) ** 2
|
||||||
|
|
||||||
if order.direction == Order.SELL:
|
if order.direction == Order.SELL:
|
||||||
cost_ratio = self.close_cost
|
cost_ratio = self.close_cost + adj_cost_ratio
|
||||||
# sell
|
# sell
|
||||||
# if we don't know current position, we choose to sell all
|
# if we don't know current position, we choose to sell all
|
||||||
# Otherwise, we clip the amount based on current position
|
# Otherwise, we clip the amount based on current position
|
||||||
@@ -750,14 +762,18 @@ class Exchange:
|
|||||||
self.logger.debug(f"Order clipped due to cash limitation: {order}")
|
self.logger.debug(f"Order clipped due to cash limitation: {order}")
|
||||||
|
|
||||||
elif order.direction == Order.BUY:
|
elif order.direction == Order.BUY:
|
||||||
cost_ratio = self.open_cost
|
cost_ratio = self.open_cost + adj_cost_ratio
|
||||||
# buy
|
# buy
|
||||||
if position is not None:
|
if position is not None:
|
||||||
cash = position.get_cash()
|
cash = position.get_cash()
|
||||||
trade_val = order.deal_amount * trade_price
|
trade_val = order.deal_amount * trade_price
|
||||||
if cash < trade_val + max(trade_val * cost_ratio, self.min_cost):
|
if cash < max(trade_val * cost_ratio, self.min_cost):
|
||||||
|
# cash cannot cover cost
|
||||||
|
order.deal_amount = 0
|
||||||
|
self.logger.debug(f"Order clipped due to cost higher than cash: {order}")
|
||||||
|
elif cash < trade_val + max(trade_val * cost_ratio, self.min_cost):
|
||||||
# The money is not enough
|
# The money is not enough
|
||||||
max_buy_amount = self._get_buy_amount_by_cash_limit(trade_price, cash)
|
max_buy_amount = self._get_buy_amount_by_cash_limit(trade_price, cash, cost_ratio)
|
||||||
order.deal_amount = self.round_amount_by_trade_unit(
|
order.deal_amount = self.round_amount_by_trade_unit(
|
||||||
min(max_buy_amount, order.deal_amount), order.factor
|
min(max_buy_amount, order.deal_amount), order.factor
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -160,6 +160,11 @@ class NumpyQuote(BaseQuote):
|
|||||||
if is_single_value(start_time, end_time, self.freq, self.region):
|
if is_single_value(start_time, end_time, self.freq, self.region):
|
||||||
# this is a very special case.
|
# this is a very special case.
|
||||||
# skip aggregating function to speed-up the query calculation
|
# skip aggregating function to speed-up the query calculation
|
||||||
|
|
||||||
|
# FIXME:
|
||||||
|
# it will go to the else logic when it comes to the
|
||||||
|
# 1) the day before holiday when daily trading
|
||||||
|
# 2) the last minute of the day when intraday trading
|
||||||
try:
|
try:
|
||||||
return self.data[stock_id].loc[start_time, field]
|
return self.data[stock_id].loc[start_time, field]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
|||||||
102
qlib/backtest/signal.py
Normal file
102
qlib/backtest/signal.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Copyright (c) Microsoft Corporation.
|
||||||
|
# Licensed under the MIT License.
|
||||||
|
from qlib.utils import init_instance_by_config
|
||||||
|
from typing import Dict, List, Text, Tuple, Union
|
||||||
|
from ..model.base import BaseModel
|
||||||
|
from ..data.dataset import Dataset
|
||||||
|
from ..data.dataset.utils import convert_index_format
|
||||||
|
from ..utils.resam import resam_ts_data
|
||||||
|
import pandas as pd
|
||||||
|
import abc
|
||||||
|
|
||||||
|
|
||||||
|
class Signal(metaclass=abc.ABCMeta):
|
||||||
|
"""
|
||||||
|
Some trading strategy make decisions based on other prediction signals
|
||||||
|
The signals may comes from different sources(e.g. prepared data, online prediction from model and dataset)
|
||||||
|
|
||||||
|
This interface is tries to provide unified interface for those different sources
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def get_signal(self, start_time, end_time) -> Union[pd.Series, pd.DataFrame, None]:
|
||||||
|
"""
|
||||||
|
get the signal at the end of the decision step(from `start_time` to `end_time`)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Union[pd.Series, pd.DataFrame, None]:
|
||||||
|
returns None if no signal in the specific day
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class SignalWCache(Signal):
|
||||||
|
"""
|
||||||
|
Signal With pandas with based Cache
|
||||||
|
SignalWCache will store the prepared signal as a attribute and give the according signal based on input query
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, signal: Union[pd.Series, pd.DataFrame]):
|
||||||
|
"""
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
signal : Union[pd.Series, pd.DataFrame]
|
||||||
|
The expected format of the signal is like the data below (the order of index is not important and can be automatically adjusted)
|
||||||
|
|
||||||
|
instrument datetime
|
||||||
|
SH600000 2008-01-02 0.079704
|
||||||
|
2008-01-03 0.120125
|
||||||
|
2008-01-04 0.878860
|
||||||
|
2008-01-07 0.505539
|
||||||
|
2008-01-08 0.395004
|
||||||
|
"""
|
||||||
|
self.signal_cache = convert_index_format(signal, level="datetime")
|
||||||
|
|
||||||
|
def get_signal(self, start_time, end_time) -> Union[pd.Series, pd.DataFrame]:
|
||||||
|
# the frequency of the signal may not algin with the decision frequency of strategy
|
||||||
|
# so resampling from the data is necessary
|
||||||
|
# the latest signal leverage more recent data and therefore is used in trading.
|
||||||
|
signal = resam_ts_data(self.signal_cache, start_time=start_time, end_time=end_time, method="last")
|
||||||
|
return signal
|
||||||
|
|
||||||
|
|
||||||
|
class ModelSignal(SignalWCache):
|
||||||
|
def __init__(self, model: BaseModel, dataset: Dataset):
|
||||||
|
self.model = model
|
||||||
|
self.dataset = dataset
|
||||||
|
pred_scores = self.model.predict(dataset)
|
||||||
|
if isinstance(pred_scores, pd.DataFrame):
|
||||||
|
pred_scores = pred_scores.iloc[:, 0]
|
||||||
|
super().__init__(pred_scores)
|
||||||
|
|
||||||
|
def _update_model(self):
|
||||||
|
"""
|
||||||
|
When using online data, update model in each bar as the following steps:
|
||||||
|
- update dataset with online data, the dataset should support online update
|
||||||
|
- make the latest prediction scores of the new bar
|
||||||
|
- update the pred score into the latest prediction
|
||||||
|
"""
|
||||||
|
# TODO: this method is not included in the framework and could be refactor later
|
||||||
|
raise NotImplementedError("_update_model is not implemented!")
|
||||||
|
|
||||||
|
|
||||||
|
def create_signal_from(
|
||||||
|
obj: Union[Signal, Tuple[BaseModel, Dataset], List, Dict, Text, pd.Series, pd.DataFrame]
|
||||||
|
) -> Signal:
|
||||||
|
"""
|
||||||
|
create signal from diverse information
|
||||||
|
This method will choose the right method to create a signal based on `obj`
|
||||||
|
Please refer to the code below.
|
||||||
|
"""
|
||||||
|
if isinstance(obj, Signal):
|
||||||
|
return obj
|
||||||
|
elif isinstance(obj, (tuple, list)):
|
||||||
|
return ModelSignal(*obj)
|
||||||
|
elif isinstance(obj, (dict, str)):
|
||||||
|
return init_instance_by_config(obj)
|
||||||
|
elif isinstance(obj, (pd.DataFrame, pd.Series)):
|
||||||
|
return SignalWCache(signal=obj)
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(f"This type of signal is not supported")
|
||||||
@@ -70,7 +70,7 @@ class TradeCalendarManager:
|
|||||||
- If self.trade_step >= self.self.trade_len, it means the trading is finished
|
- If self.trade_step >= self.self.trade_len, it means the trading is finished
|
||||||
- If self.trade_step < self.self.trade_len, it means the number of trading step finished is self.trade_step
|
- If self.trade_step < self.self.trade_len, it means the number of trading step finished is self.trade_step
|
||||||
"""
|
"""
|
||||||
return self.trade_step >= self.trade_len
|
return self.trade_step >= self.trade_len - 1
|
||||||
|
|
||||||
def step(self):
|
def step(self):
|
||||||
if self.finished():
|
if self.finished():
|
||||||
|
|||||||
0
qlib/contrib/data/utils/__init__.py
Normal file
0
qlib/contrib/data/utils/__init__.py
Normal file
183
qlib/contrib/data/utils/sepdf.py
Normal file
183
qlib/contrib/data/utils/sepdf.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# Copyright (c) Microsoft Corporation.
|
||||||
|
# Licensed under the MIT License.
|
||||||
|
import pandas as pd
|
||||||
|
from typing import Dict, Iterable
|
||||||
|
|
||||||
|
|
||||||
|
def align_index(df_dict, join):
|
||||||
|
res = {}
|
||||||
|
for k, df in df_dict.items():
|
||||||
|
if join is not None and k != join:
|
||||||
|
df = df.reindex(df_dict[join].index)
|
||||||
|
res[k] = df
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# Mocking the pd.DataFrame class
|
||||||
|
class SepDataFrame:
|
||||||
|
"""
|
||||||
|
(Sep)erate DataFrame
|
||||||
|
We usually concat multiple dataframe to be processed together(Such as feature, label, weight, filter).
|
||||||
|
However, they are usally be used seperately at last.
|
||||||
|
This will result in extra cost for concating and spliting data(reshaping and copying data in the memory is very expensive)
|
||||||
|
|
||||||
|
SepDataFrame tries to act like a DataFrame whose column with multiindex
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, df_dict: Dict[str, pd.DataFrame], join: str, skip_align=False):
|
||||||
|
"""
|
||||||
|
initialize the data based on the dataframe dictionary
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
df_dict : Dict[str, pd.DataFrame]
|
||||||
|
dataframe dictionary
|
||||||
|
join : str
|
||||||
|
how to join the data
|
||||||
|
It will reindex the dataframe based on the join key.
|
||||||
|
If join is None, the reindex step will be skipped
|
||||||
|
|
||||||
|
skip_align :
|
||||||
|
for some cases, we can improve performance by skipping aligning index
|
||||||
|
"""
|
||||||
|
self.join = join
|
||||||
|
|
||||||
|
if skip_align:
|
||||||
|
self._df_dict = df_dict
|
||||||
|
else:
|
||||||
|
self._df_dict = align_index(df_dict, join)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loc(self):
|
||||||
|
return SDFLoc(self, join=self.join)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def index(self):
|
||||||
|
return self._df_dict[self.join].index
|
||||||
|
|
||||||
|
def apply_each(self, method: str, skip_align=True, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Assumptions:
|
||||||
|
- inplace methods will return None
|
||||||
|
"""
|
||||||
|
inplace = False
|
||||||
|
df_dict = {}
|
||||||
|
for k, df in self._df_dict.items():
|
||||||
|
df_dict[k] = getattr(df, method)(*args, **kwargs)
|
||||||
|
if df_dict[k] is None:
|
||||||
|
inplace = True
|
||||||
|
if not inplace:
|
||||||
|
return SepDataFrame(df_dict=df_dict, join=self.join, skip_align=skip_align)
|
||||||
|
|
||||||
|
def sort_index(self, *args, **kwargs):
|
||||||
|
return self.apply_each("sort_index", True, *args, **kwargs)
|
||||||
|
|
||||||
|
def copy(self, *args, **kwargs):
|
||||||
|
return self.apply_each("copy", True, *args, **kwargs)
|
||||||
|
|
||||||
|
def _update_join(self):
|
||||||
|
if self.join not in self:
|
||||||
|
self.join = next(iter(self._df_dict.keys()))
|
||||||
|
|
||||||
|
def __getitem__(self, item):
|
||||||
|
return self._df_dict[item]
|
||||||
|
|
||||||
|
def __setitem__(self, item: str, df: pd.DataFrame):
|
||||||
|
# TODO: consider the join behavior
|
||||||
|
self._df_dict[item] = df
|
||||||
|
|
||||||
|
def __delitem__(self, item: str):
|
||||||
|
del self._df_dict[item]
|
||||||
|
self._update_join()
|
||||||
|
|
||||||
|
def __contains__(self, item):
|
||||||
|
return item in self._df_dict
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self._df_dict[self.join])
|
||||||
|
|
||||||
|
def droplevel(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError(f"Please implement the `droplevel` method")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def columns(self):
|
||||||
|
dfs = []
|
||||||
|
for k, df in self._df_dict.items():
|
||||||
|
df = df.head(0)
|
||||||
|
df.columns = pd.MultiIndex.from_product([[k], df.columns])
|
||||||
|
dfs.append(df)
|
||||||
|
return pd.concat(dfs, axis=1).columns
|
||||||
|
|
||||||
|
# Useless methods
|
||||||
|
@staticmethod
|
||||||
|
def merge(df_dict: Dict[str, pd.DataFrame], join: str):
|
||||||
|
all_df = df_dict[join]
|
||||||
|
for k, df in df_dict.items():
|
||||||
|
if k != join:
|
||||||
|
all_df = all_df.join(df)
|
||||||
|
return all_df
|
||||||
|
|
||||||
|
|
||||||
|
class SDFLoc:
|
||||||
|
"""Mock Class"""
|
||||||
|
|
||||||
|
def __init__(self, sdf: SepDataFrame, join):
|
||||||
|
self._sdf = sdf
|
||||||
|
self.axis = None
|
||||||
|
self.join = join
|
||||||
|
|
||||||
|
def __call__(self, axis):
|
||||||
|
self.axis = axis
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __getitem__(self, args):
|
||||||
|
if self.axis == 1:
|
||||||
|
if isinstance(args, str):
|
||||||
|
return self._sdf[args]
|
||||||
|
elif isinstance(args, (tuple, list)):
|
||||||
|
new_df_dict = {k: self._sdf[k] for k in args}
|
||||||
|
return SepDataFrame(new_df_dict, join=self.join if self.join in args else args[0], skip_align=True)
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(f"This type of input is not supported")
|
||||||
|
elif self.axis == 0:
|
||||||
|
return SepDataFrame(
|
||||||
|
{k: df.loc(axis=0)[args] for k, df in self._sdf._df_dict.items()}, join=self.join, skip_align=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
df = self._sdf
|
||||||
|
if isinstance(args, tuple):
|
||||||
|
ax0, *ax1 = args
|
||||||
|
if len(ax1) == 0:
|
||||||
|
ax1 = None
|
||||||
|
if ax1 is not None:
|
||||||
|
df = df.loc(axis=1)[ax1]
|
||||||
|
if ax0 is not None:
|
||||||
|
df = df.loc(axis=0)[ax0]
|
||||||
|
return df
|
||||||
|
else:
|
||||||
|
return df.loc(axis=0)[args]
|
||||||
|
|
||||||
|
|
||||||
|
# Patch pandas DataFrame
|
||||||
|
# Tricking isinstance to accept SepDataFrame as its subclass
|
||||||
|
import builtins
|
||||||
|
|
||||||
|
|
||||||
|
def _isinstance(instance, cls):
|
||||||
|
if isinstance_orig(instance, SepDataFrame): # pylint: disable=E0602
|
||||||
|
if isinstance(cls, Iterable):
|
||||||
|
for c in cls:
|
||||||
|
if c is pd.DataFrame:
|
||||||
|
return True
|
||||||
|
elif cls is pd.DataFrame:
|
||||||
|
return True
|
||||||
|
return isinstance_orig(instance, cls) # pylint: disable=E0602
|
||||||
|
|
||||||
|
|
||||||
|
builtins.isinstance_orig = builtins.isinstance
|
||||||
|
builtins.isinstance = _isinstance
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sdf = SepDataFrame({}, join=None)
|
||||||
|
print(isinstance(sdf, (pd.DataFrame,)))
|
||||||
|
print(isinstance(sdf, pd.DataFrame))
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
# Licensed under the MIT License.
|
# Licensed under the MIT License.
|
||||||
|
|
||||||
|
|
||||||
from .model_strategy import (
|
from .signal_strategy import (
|
||||||
TopkDropoutStrategy,
|
TopkDropoutStrategy,
|
||||||
WeightStrategyBase,
|
WeightStrategyBase,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This strategy is not well maintained
|
|||||||
|
|
||||||
|
|
||||||
from .order_generator import OrderGenWInteract
|
from .order_generator import OrderGenWInteract
|
||||||
from .model_strategy import WeightStrategyBase
|
from .signal_strategy import WeightStrategyBase
|
||||||
import copy
|
import copy
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,18 +80,22 @@ class OrderGenWInteract(OrderGenerator):
|
|||||||
|
|
||||||
:rtype: list
|
:rtype: list
|
||||||
"""
|
"""
|
||||||
|
if target_weight_position is None:
|
||||||
|
return []
|
||||||
|
|
||||||
# calculate current_tradable_value
|
# calculate current_tradable_value
|
||||||
current_amount_dict = current.get_stock_amount_dict()
|
current_amount_dict = current.get_stock_amount_dict()
|
||||||
|
|
||||||
current_total_value = trade_exchange.calculate_amount_position_value(
|
current_total_value = trade_exchange.calculate_amount_position_value(
|
||||||
amount_dict=current_amount_dict,
|
amount_dict=current_amount_dict,
|
||||||
trade_start_time=trade_start_time,
|
start_time=trade_start_time,
|
||||||
trade_end_time=trade_end_time,
|
end_time=trade_end_time,
|
||||||
only_tradable=False,
|
only_tradable=False,
|
||||||
)
|
)
|
||||||
current_tradable_value = trade_exchange.calculate_amount_position_value(
|
current_tradable_value = trade_exchange.calculate_amount_position_value(
|
||||||
amount_dict=current_amount_dict,
|
amount_dict=current_amount_dict,
|
||||||
trade_start_time=trade_start_time,
|
start_time=trade_start_time,
|
||||||
trade_end_time=trade_end_time,
|
end_time=trade_end_time,
|
||||||
only_tradable=True,
|
only_tradable=True,
|
||||||
)
|
)
|
||||||
# add cash
|
# add cash
|
||||||
@@ -105,9 +109,7 @@ class OrderGenWInteract(OrderGenerator):
|
|||||||
# value. Then just sell all the stocks
|
# value. Then just sell all the stocks
|
||||||
target_amount_dict = copy.deepcopy(current_amount_dict.copy())
|
target_amount_dict = copy.deepcopy(current_amount_dict.copy())
|
||||||
for stock_id in list(target_amount_dict.keys()):
|
for stock_id in list(target_amount_dict.keys()):
|
||||||
if trade_exchange.is_stock_tradable(
|
if trade_exchange.is_stock_tradable(stock_id, start_time=trade_start_time, end_time=trade_end_time):
|
||||||
stock_id, trade_start_time=trade_start_time, trade_end_time=trade_end_time
|
|
||||||
):
|
|
||||||
del target_amount_dict[stock_id]
|
del target_amount_dict[stock_id]
|
||||||
else:
|
else:
|
||||||
# consider cost rate
|
# consider cost rate
|
||||||
@@ -118,16 +120,16 @@ class OrderGenWInteract(OrderGenerator):
|
|||||||
target_amount_dict = trade_exchange.generate_amount_position_from_weight_position(
|
target_amount_dict = trade_exchange.generate_amount_position_from_weight_position(
|
||||||
weight_position=target_weight_position,
|
weight_position=target_weight_position,
|
||||||
cash=current_tradable_value,
|
cash=current_tradable_value,
|
||||||
trade_start_time=trade_start_time,
|
start_time=trade_start_time,
|
||||||
trade_end_time=trade_end_time,
|
end_time=trade_end_time,
|
||||||
)
|
)
|
||||||
order_list = trade_exchange.generate_order_for_target_amount_position(
|
order_list = trade_exchange.generate_order_for_target_amount_position(
|
||||||
target_position=target_amount_dict,
|
target_position=target_amount_dict,
|
||||||
current_position=current_amount_dict,
|
current_position=current_amount_dict,
|
||||||
trade_start_time=trade_start_time,
|
start_time=trade_start_time,
|
||||||
trade_end_time=trade_end_time,
|
end_time=trade_end_time,
|
||||||
)
|
)
|
||||||
return TradeDecisionWO(order_list, self)
|
return order_list
|
||||||
|
|
||||||
|
|
||||||
class OrderGenWOInteract(OrderGenerator):
|
class OrderGenWOInteract(OrderGenerator):
|
||||||
@@ -163,8 +165,11 @@ class OrderGenWOInteract(OrderGenerator):
|
|||||||
:param trade_date:
|
:param trade_date:
|
||||||
:type trade_date: pd.Timestamp
|
:type trade_date: pd.Timestamp
|
||||||
|
|
||||||
:rtype: list
|
:rtype: list of generated orders
|
||||||
"""
|
"""
|
||||||
|
if target_weight_position is None:
|
||||||
|
return []
|
||||||
|
|
||||||
risk_total_value = risk_degree * current.calculate_value()
|
risk_total_value = risk_degree * current.calculate_value()
|
||||||
|
|
||||||
current_stock = current.get_stock_list()
|
current_stock = current.get_stock_list()
|
||||||
@@ -172,13 +177,17 @@ class OrderGenWOInteract(OrderGenerator):
|
|||||||
for stock_id in target_weight_position:
|
for stock_id in target_weight_position:
|
||||||
# Current rule will ignore the stock that not hold and cannot be traded at predict date
|
# Current rule will ignore the stock that not hold and cannot be traded at predict date
|
||||||
if trade_exchange.is_stock_tradable(
|
if trade_exchange.is_stock_tradable(
|
||||||
stock_id=stock_id, trade_start_time=trade_start_time, trade_end_time=trade_end_time
|
stock_id=stock_id, start_time=trade_start_time, end_time=trade_end_time
|
||||||
|
) and trade_exchange.is_stock_tradable(
|
||||||
|
stock_id=stock_id, start_time=pred_start_time, end_time=pred_end_time
|
||||||
):
|
):
|
||||||
amount_dict[stock_id] = (
|
amount_dict[stock_id] = (
|
||||||
risk_total_value
|
risk_total_value
|
||||||
* target_weight_position[stock_id]
|
* target_weight_position[stock_id]
|
||||||
/ trade_exchange.get_close(stock_id, trade_start_time=pred_start_time, trade_end_time=pred_end_time)
|
/ trade_exchange.get_close(stock_id, start_time=pred_start_time, end_time=pred_end_time)
|
||||||
)
|
)
|
||||||
|
# TODO: Qlib use None to represent trading suspension. So last close price can't be the estimated trading price.
|
||||||
|
# Maybe a close price with forward fill will be a better solution.
|
||||||
elif stock_id in current_stock:
|
elif stock_id in current_stock:
|
||||||
amount_dict[stock_id] = (
|
amount_dict[stock_id] = (
|
||||||
risk_total_value * target_weight_position[stock_id] / current.get_stock_price(stock_id)
|
risk_total_value * target_weight_position[stock_id] / current.get_stock_price(stock_id)
|
||||||
@@ -188,7 +197,7 @@ class OrderGenWOInteract(OrderGenerator):
|
|||||||
order_list = trade_exchange.generate_order_for_target_amount_position(
|
order_list = trade_exchange.generate_order_for_target_amount_position(
|
||||||
target_position=amount_dict,
|
target_position=amount_dict,
|
||||||
current_position=current.get_stock_amount_dict(),
|
current_position=current.get_stock_amount_dict(),
|
||||||
trade_start_time=trade_start_time,
|
start_time=trade_start_time,
|
||||||
trade_end_time=trade_end_time,
|
end_time=trade_end_time,
|
||||||
)
|
)
|
||||||
return TradeDecisionWO(order_list, self)
|
return order_list
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# Copyright (c) Microsoft Corporation.
|
||||||
|
# Licensed under the MIT License.
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import warnings
|
import warnings
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|||||||
@@ -1,27 +1,33 @@
|
|||||||
|
# Copyright (c) Microsoft Corporation.
|
||||||
|
# Licensed under the MIT License.
|
||||||
import copy
|
import copy
|
||||||
|
from qlib.backtest.signal import Signal, create_signal_from
|
||||||
|
from typing import Dict, List, Text, Tuple, Union
|
||||||
|
from qlib.data.dataset import Dataset
|
||||||
|
from qlib.model.base import BaseModel
|
||||||
from qlib.backtest.position import Position
|
from qlib.backtest.position import Position
|
||||||
import warnings
|
import warnings
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from ...utils.resam import resam_ts_data
|
from ...utils.resam import resam_ts_data
|
||||||
from ...strategy.base import ModelStrategy
|
from ...strategy.base import BaseStrategy
|
||||||
from ...backtest.decision import Order, BaseTradeDecision, OrderDir, TradeDecisionWO
|
from ...backtest.decision import Order, BaseTradeDecision, OrderDir, TradeDecisionWO
|
||||||
|
|
||||||
from .order_generator import OrderGenWInteract
|
from .order_generator import OrderGenWInteract
|
||||||
|
|
||||||
|
|
||||||
class TopkDropoutStrategy(ModelStrategy):
|
class TopkDropoutStrategy(BaseStrategy):
|
||||||
# TODO:
|
# TODO:
|
||||||
# 1. Supporting leverage the get_range_limit result from the decision
|
# 1. Supporting leverage the get_range_limit result from the decision
|
||||||
# 2. Supporting alter_outer_trade_decision
|
# 2. Supporting alter_outer_trade_decision
|
||||||
# 3. Supporting checking the availability of trade decision
|
# 3. Supporting checking the availability of trade decision
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
model,
|
*,
|
||||||
dataset,
|
|
||||||
topk,
|
topk,
|
||||||
n_drop,
|
n_drop,
|
||||||
|
signal: Union[Signal, Tuple[BaseModel, Dataset], List, Dict, Text, pd.Series, pd.DataFrame] = None,
|
||||||
method_sell="bottom",
|
method_sell="bottom",
|
||||||
method_buy="top",
|
method_buy="top",
|
||||||
risk_degree=0.95,
|
risk_degree=0.95,
|
||||||
@@ -30,6 +36,8 @@ class TopkDropoutStrategy(ModelStrategy):
|
|||||||
trade_exchange=None,
|
trade_exchange=None,
|
||||||
level_infra=None,
|
level_infra=None,
|
||||||
common_infra=None,
|
common_infra=None,
|
||||||
|
model=None,
|
||||||
|
dataset=None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -39,6 +47,9 @@ class TopkDropoutStrategy(ModelStrategy):
|
|||||||
the number of stocks in the portfolio.
|
the number of stocks in the portfolio.
|
||||||
n_drop : int
|
n_drop : int
|
||||||
number of stocks to be replaced in each trading date.
|
number of stocks to be replaced in each trading date.
|
||||||
|
signal :
|
||||||
|
the information to describe a signal. Please refer to the docs of `qlib.backtest.signal.create_signal_from`
|
||||||
|
the decision of the strategy will base on the given signal
|
||||||
method_sell : str
|
method_sell : str
|
||||||
dropout method_sell, random/bottom.
|
dropout method_sell, random/bottom.
|
||||||
method_buy : str
|
method_buy : str
|
||||||
@@ -64,7 +75,7 @@ class TopkDropoutStrategy(ModelStrategy):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
super(TopkDropoutStrategy, self).__init__(
|
super(TopkDropoutStrategy, self).__init__(
|
||||||
model, dataset, level_infra=level_infra, common_infra=common_infra, trade_exchange=trade_exchange, **kwargs
|
level_infra=level_infra, common_infra=common_infra, trade_exchange=trade_exchange, **kwargs
|
||||||
)
|
)
|
||||||
self.topk = topk
|
self.topk = topk
|
||||||
self.n_drop = n_drop
|
self.n_drop = n_drop
|
||||||
@@ -74,6 +85,13 @@ class TopkDropoutStrategy(ModelStrategy):
|
|||||||
self.hold_thresh = hold_thresh
|
self.hold_thresh = hold_thresh
|
||||||
self.only_tradable = only_tradable
|
self.only_tradable = only_tradable
|
||||||
|
|
||||||
|
# This is trying to be compatible with previous version of qlib task config
|
||||||
|
if model is not None and dataset is not None:
|
||||||
|
warnings.warn("`model` `dataset` is deprecated; use `signal`.", DeprecationWarning)
|
||||||
|
signal = model, dataset
|
||||||
|
|
||||||
|
self.signal: Signal = create_signal_from(signal)
|
||||||
|
|
||||||
def get_risk_degree(self, trade_step=None):
|
def get_risk_degree(self, trade_step=None):
|
||||||
"""get_risk_degree
|
"""get_risk_degree
|
||||||
Return the proportion of your total value you will used in investment.
|
Return the proportion of your total value you will used in investment.
|
||||||
@@ -87,7 +105,7 @@ class TopkDropoutStrategy(ModelStrategy):
|
|||||||
trade_step = self.trade_calendar.get_trade_step()
|
trade_step = self.trade_calendar.get_trade_step()
|
||||||
trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step)
|
trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step)
|
||||||
pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1)
|
pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1)
|
||||||
pred_score = resam_ts_data(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last")
|
pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time)
|
||||||
if pred_score is None:
|
if pred_score is None:
|
||||||
return TradeDecisionWO([], self)
|
return TradeDecisionWO([], self)
|
||||||
if self.only_tradable:
|
if self.only_tradable:
|
||||||
@@ -235,15 +253,15 @@ class TopkDropoutStrategy(ModelStrategy):
|
|||||||
return TradeDecisionWO(sell_order_list + buy_order_list, self)
|
return TradeDecisionWO(sell_order_list + buy_order_list, self)
|
||||||
|
|
||||||
|
|
||||||
class WeightStrategyBase(ModelStrategy):
|
class WeightStrategyBase(BaseStrategy):
|
||||||
# TODO:
|
# TODO:
|
||||||
# 1. Supporting leverage the get_range_limit result from the decision
|
# 1. Supporting leverage the get_range_limit result from the decision
|
||||||
# 2. Supporting alter_outer_trade_decision
|
# 2. Supporting alter_outer_trade_decision
|
||||||
# 3. Supporting checking the availability of trade decision
|
# 3. Supporting checking the availability of trade decision
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
model,
|
*,
|
||||||
dataset,
|
signal: Union[Signal, Tuple[BaseModel, Dataset], List, Dict, Text, pd.Series, pd.DataFrame],
|
||||||
order_generator_cls_or_obj=OrderGenWInteract,
|
order_generator_cls_or_obj=OrderGenWInteract,
|
||||||
trade_exchange=None,
|
trade_exchange=None,
|
||||||
level_infra=None,
|
level_infra=None,
|
||||||
@@ -251,6 +269,9 @@ class WeightStrategyBase(ModelStrategy):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
signal :
|
||||||
|
the information to describe a signal. Please refer to the docs of `qlib.backtest.signal.create_signal_from`
|
||||||
|
the decision of the strategy will base on the given signal
|
||||||
trade_exchange : Exchange
|
trade_exchange : Exchange
|
||||||
exchange that provides market info, used to deal order and generate report
|
exchange that provides market info, used to deal order and generate report
|
||||||
- If `trade_exchange` is None, self.trade_exchange will be set with common_infra
|
- If `trade_exchange` is None, self.trade_exchange will be set with common_infra
|
||||||
@@ -260,13 +281,15 @@ class WeightStrategyBase(ModelStrategy):
|
|||||||
- In minutely execution, the daily exchange is not usable, only the minutely exchange is recommended.
|
- In minutely execution, the daily exchange is not usable, only the minutely exchange is recommended.
|
||||||
"""
|
"""
|
||||||
super(WeightStrategyBase, self).__init__(
|
super(WeightStrategyBase, self).__init__(
|
||||||
model, dataset, level_infra=level_infra, common_infra=common_infra, trade_exchange=trade_exchange, **kwargs
|
level_infra=level_infra, common_infra=common_infra, trade_exchange=trade_exchange, **kwargs
|
||||||
)
|
)
|
||||||
if isinstance(order_generator_cls_or_obj, type):
|
if isinstance(order_generator_cls_or_obj, type):
|
||||||
self.order_generator = order_generator_cls_or_obj()
|
self.order_generator = order_generator_cls_or_obj()
|
||||||
else:
|
else:
|
||||||
self.order_generator = order_generator_cls_or_obj
|
self.order_generator = order_generator_cls_or_obj
|
||||||
|
|
||||||
|
self.signal: Signal = create_signal_from(signal)
|
||||||
|
|
||||||
def get_risk_degree(self, trade_step=None):
|
def get_risk_degree(self, trade_step=None):
|
||||||
"""get_risk_degree
|
"""get_risk_degree
|
||||||
Return the proportion of your total value you will used in investment.
|
Return the proportion of your total value you will used in investment.
|
||||||
@@ -298,7 +321,7 @@ class WeightStrategyBase(ModelStrategy):
|
|||||||
trade_step = self.trade_calendar.get_trade_step()
|
trade_step = self.trade_calendar.get_trade_step()
|
||||||
trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step)
|
trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step)
|
||||||
pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1)
|
pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1)
|
||||||
pred_score = resam_ts_data(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last")
|
pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time)
|
||||||
if pred_score is None:
|
if pred_score is None:
|
||||||
return TradeDecisionWO([], self)
|
return TradeDecisionWO([], self)
|
||||||
current_temp = copy.deepcopy(self.trade_position)
|
current_temp = copy.deepcopy(self.trade_position)
|
||||||
@@ -49,7 +49,7 @@ class MultiSegRecord(RecordTemp):
|
|||||||
|
|
||||||
if save:
|
if save:
|
||||||
save_name = "results-{:}.pkl".format(key)
|
save_name = "results-{:}.pkl".format(key)
|
||||||
self.recorder.save_objects(**{save_name: results})
|
self.save(**{save_name: results})
|
||||||
logger.info(
|
logger.info(
|
||||||
"The record '{:}' has been saved as the artifact of the Experiment {:}".format(
|
"The record '{:}' has been saved as the artifact of the Experiment {:}".format(
|
||||||
save_name, self.recorder.experiment_id
|
save_name, self.recorder.experiment_id
|
||||||
@@ -79,9 +79,8 @@ class SignalMseRecord(RecordTemp):
|
|||||||
metrics = {"MSE": mse, "RMSE": np.sqrt(mse)}
|
metrics = {"MSE": mse, "RMSE": np.sqrt(mse)}
|
||||||
objects = {"mse.pkl": mse, "rmse.pkl": np.sqrt(mse)}
|
objects = {"mse.pkl": mse, "rmse.pkl": np.sqrt(mse)}
|
||||||
self.recorder.log_metrics(**metrics)
|
self.recorder.log_metrics(**metrics)
|
||||||
self.recorder.save_objects(**objects, artifact_path=self.get_path())
|
self.save(**objects)
|
||||||
logger.info("The evaluation results in SignalMseRecord is {:}".format(metrics))
|
logger.info("The evaluation results in SignalMseRecord is {:}".format(metrics))
|
||||||
|
|
||||||
def list(self):
|
def list(self):
|
||||||
paths = [self.get_path("mse.pkl"), self.get_path("rmse.pkl")]
|
return ["mse.pkl", "rmse.pkl"]
|
||||||
return paths
|
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ class TSDataSampler:
|
|||||||
self.flt_data = flt_data.values
|
self.flt_data = flt_data.values
|
||||||
self.idx_map = self.flt_idx_map(self.flt_data, self.idx_map)
|
self.idx_map = self.flt_idx_map(self.flt_data, self.idx_map)
|
||||||
self.data_index = self.data_index[np.where(self.flt_data == True)[0]]
|
self.data_index = self.data_index[np.where(self.flt_data == True)[0]]
|
||||||
|
self.idx_map = self.idx_map2arr(self.idx_map)
|
||||||
|
|
||||||
self.start_idx, self.end_idx = self.data_index.slice_locs(
|
self.start_idx, self.end_idx = self.data_index.slice_locs(
|
||||||
start=time_to_slc_point(start), end=time_to_slc_point(end)
|
start=time_to_slc_point(start), end=time_to_slc_point(end)
|
||||||
@@ -328,6 +329,25 @@ class TSDataSampler:
|
|||||||
|
|
||||||
del self.data # save memory
|
del self.data # save memory
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def idx_map2arr(idx_map):
|
||||||
|
# pytorch data sampler will have better memory control without large dict or list
|
||||||
|
# - https://github.com/pytorch/pytorch/issues/13243
|
||||||
|
# - https://github.com/airctic/icevision/issues/613
|
||||||
|
# So we convert the dict into int array.
|
||||||
|
# The arr_map is expected to behave the same as idx_map
|
||||||
|
|
||||||
|
dtype = np.int32
|
||||||
|
# set a index out of bound to indicate the none existing
|
||||||
|
no_existing_idx = (np.iinfo(dtype).max, np.iinfo(dtype).max)
|
||||||
|
|
||||||
|
max_idx = max(idx_map.keys())
|
||||||
|
arr_map = []
|
||||||
|
for i in range(max_idx + 1):
|
||||||
|
arr_map.append(idx_map.get(i, no_existing_idx))
|
||||||
|
arr_map = np.array(arr_map, dtype=dtype)
|
||||||
|
return arr_map
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def flt_idx_map(flt_data, idx_map):
|
def flt_idx_map(flt_data, idx_map):
|
||||||
idx = 0
|
idx = 0
|
||||||
@@ -524,20 +544,18 @@ class TSDatasetH(DatasetH):
|
|||||||
|
|
||||||
def setup_data(self, **kwargs):
|
def setup_data(self, **kwargs):
|
||||||
super().setup_data(**kwargs)
|
super().setup_data(**kwargs)
|
||||||
|
# make sure the calendar is updated to latest when loading data from new config
|
||||||
cal = self.handler.fetch(col_set=self.handler.CS_RAW).index.get_level_values("datetime").unique()
|
cal = self.handler.fetch(col_set=self.handler.CS_RAW).index.get_level_values("datetime").unique()
|
||||||
cal = sorted(cal)
|
self.cal = sorted(cal)
|
||||||
self.cal = cal
|
|
||||||
|
|
||||||
def _prepare_raw_seg(self, slc: slice, **kwargs) -> pd.DataFrame:
|
@staticmethod
|
||||||
|
def _extend_slice(slc: slice, cal: list, step_len: int) -> slice:
|
||||||
# Dataset decide how to slice data(Get more data for timeseries).
|
# Dataset decide how to slice data(Get more data for timeseries).
|
||||||
start, end = slc.start, slc.stop
|
start, end = slc.start, slc.stop
|
||||||
start_idx = bisect.bisect_left(self.cal, pd.Timestamp(start))
|
start_idx = bisect.bisect_left(cal, pd.Timestamp(start))
|
||||||
pad_start_idx = max(0, start_idx - self.step_len)
|
pad_start_idx = max(0, start_idx - step_len)
|
||||||
pad_start = self.cal[pad_start_idx]
|
pad_start = cal[pad_start_idx]
|
||||||
|
return slice(pad_start, end)
|
||||||
# TSDatasetH will retrieve more data for complete
|
|
||||||
data = super()._prepare_seg(slice(pad_start, end), **kwargs)
|
|
||||||
return data
|
|
||||||
|
|
||||||
def _prepare_seg(self, slc: slice, **kwargs) -> TSDataSampler:
|
def _prepare_seg(self, slc: slice, **kwargs) -> TSDataSampler:
|
||||||
"""
|
"""
|
||||||
@@ -546,13 +564,15 @@ class TSDatasetH(DatasetH):
|
|||||||
dtype = kwargs.pop("dtype", None)
|
dtype = kwargs.pop("dtype", None)
|
||||||
start, end = slc.start, slc.stop
|
start, end = slc.start, slc.stop
|
||||||
flt_col = kwargs.pop("flt_col", None)
|
flt_col = kwargs.pop("flt_col", None)
|
||||||
# TSDatasetH will retrieve more data for complete
|
# TSDatasetH will retrieve more data for complete time-series
|
||||||
data = self._prepare_raw_seg(slc, **kwargs)
|
|
||||||
|
ext_slice = self._extend_slice(slc, self.cal, self.step_len)
|
||||||
|
data = super()._prepare_seg(ext_slice, **kwargs)
|
||||||
|
|
||||||
flt_kwargs = deepcopy(kwargs)
|
flt_kwargs = deepcopy(kwargs)
|
||||||
if flt_col is not None:
|
if flt_col is not None:
|
||||||
flt_kwargs["col_set"] = flt_col
|
flt_kwargs["col_set"] = flt_col
|
||||||
flt_data = self._prepare_raw_seg(slc, **flt_kwargs)
|
flt_data = self._prepare_seg(ext_slice, **flt_kwargs)
|
||||||
assert len(flt_data.columns) == 1
|
assert len(flt_data.columns) == 1
|
||||||
else:
|
else:
|
||||||
flt_data = None
|
flt_data = None
|
||||||
|
|||||||
@@ -82,8 +82,6 @@ class DataHandler(Serializable):
|
|||||||
fetch_orig : bool
|
fetch_orig : bool
|
||||||
Return the original data instead of copy if possible.
|
Return the original data instead of copy if possible.
|
||||||
"""
|
"""
|
||||||
# Set logger
|
|
||||||
self.logger = get_module_logger("DataHandler")
|
|
||||||
|
|
||||||
# Setup data loader
|
# Setup data loader
|
||||||
assert data_loader is not None # to make start_time end_time could have None default value
|
assert data_loader is not None # to make start_time end_time could have None default value
|
||||||
@@ -302,6 +300,7 @@ class DataHandlerLP(DataHandler):
|
|||||||
DK_R = "raw"
|
DK_R = "raw"
|
||||||
DK_I = "infer"
|
DK_I = "infer"
|
||||||
DK_L = "learn"
|
DK_L = "learn"
|
||||||
|
ATTR_MAP = {DK_R: "_data", DK_I: "_infer", DK_L: "_learn"}
|
||||||
|
|
||||||
# process type
|
# process type
|
||||||
PTYPE_I = "independent"
|
PTYPE_I = "independent"
|
||||||
@@ -543,7 +542,7 @@ class DataHandlerLP(DataHandler):
|
|||||||
raise AttributeError(
|
raise AttributeError(
|
||||||
"DataHandlerLP has not attribute _data, please set drop_raw = False if you want to use raw data"
|
"DataHandlerLP has not attribute _data, please set drop_raw = False if you want to use raw data"
|
||||||
)
|
)
|
||||||
df = getattr(self, {self.DK_R: "_data", self.DK_I: "_infer", self.DK_L: "_learn"}[data_key])
|
df = getattr(self, self.ATTR_MAP[data_key])
|
||||||
return df
|
return df
|
||||||
|
|
||||||
def fetch(
|
def fetch(
|
||||||
@@ -624,3 +623,33 @@ class DataHandlerLP(DataHandler):
|
|||||||
df = self._get_df_by_key(data_key).head()
|
df = self._get_df_by_key(data_key).head()
|
||||||
df = fetch_df_by_col(df, col_set)
|
df = fetch_df_by_col(df, col_set)
|
||||||
return df.columns.to_list()
|
return df.columns.to_list()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def cast(cls, handler: "DataHandlerLP") -> "DataHandlerLP":
|
||||||
|
"""
|
||||||
|
Motivation
|
||||||
|
- A user create a datahandler in his customized package. Then he want to share the processed handler to other users without introduce the package dependency and complicated data processing logic.
|
||||||
|
- This class make it possible by casting the class to DataHandlerLP and only keep the processed data
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
handler : DataHandlerLP
|
||||||
|
A subclass of DataHandlerLP
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
DataHandlerLP:
|
||||||
|
the converted processed data
|
||||||
|
"""
|
||||||
|
new_hd: DataHandlerLP = object.__new__(DataHandlerLP)
|
||||||
|
new_hd.from_cast = True # add a mark for the casted instance
|
||||||
|
|
||||||
|
for key in list(DataHandlerLP.ATTR_MAP.values()) + [
|
||||||
|
"instruments",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"fetch_orig",
|
||||||
|
"drop_raw",
|
||||||
|
]:
|
||||||
|
setattr(new_hd, key, getattr(handler, key, None))
|
||||||
|
return new_hd
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from ..utils import init_instance_by_config
|
|||||||
from ..backtest.utils import CommonInfrastructure, LevelInfrastructure, TradeCalendarManager
|
from ..backtest.utils import CommonInfrastructure, LevelInfrastructure, TradeCalendarManager
|
||||||
from ..backtest.decision import BaseTradeDecision
|
from ..backtest.decision import BaseTradeDecision
|
||||||
|
|
||||||
__all__ = ["BaseStrategy", "ModelStrategy", "RLStrategy", "RLIntStrategy"]
|
__all__ = ["BaseStrategy", "RLStrategy", "RLIntStrategy"]
|
||||||
|
|
||||||
|
|
||||||
class BaseStrategy:
|
class BaseStrategy:
|
||||||
@@ -194,45 +194,6 @@ class BaseStrategy:
|
|||||||
return max(cal_range[0], range_limit[0]), min(cal_range[1], range_limit[1])
|
return max(cal_range[0], range_limit[0]), min(cal_range[1], range_limit[1])
|
||||||
|
|
||||||
|
|
||||||
class ModelStrategy(BaseStrategy):
|
|
||||||
"""Model-based trading strategy, use model to make predictions for trading"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
model: BaseModel,
|
|
||||||
dataset: DatasetH,
|
|
||||||
outer_trade_decision: BaseTradeDecision = None,
|
|
||||||
level_infra: LevelInfrastructure = None,
|
|
||||||
common_infra: CommonInfrastructure = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
model : BaseModel
|
|
||||||
the model used in when making predictions
|
|
||||||
dataset : DatasetH
|
|
||||||
provide test data for model
|
|
||||||
kwargs : dict
|
|
||||||
arguments that will be passed into `reset` method
|
|
||||||
"""
|
|
||||||
super(ModelStrategy, self).__init__(outer_trade_decision, level_infra, common_infra, **kwargs)
|
|
||||||
self.model = model
|
|
||||||
self.dataset = dataset
|
|
||||||
self.pred_scores = convert_index_format(self.model.predict(dataset), level="datetime")
|
|
||||||
if isinstance(self.pred_scores, pd.DataFrame):
|
|
||||||
self.pred_scores = self.pred_scores.iloc[:, 0]
|
|
||||||
|
|
||||||
def _update_model(self):
|
|
||||||
"""
|
|
||||||
When using online data, pdate model in each bar as the following steps:
|
|
||||||
- update dataset with online data, the dataset should support online update
|
|
||||||
- make the latest prediction scores of the new bar
|
|
||||||
- update the pred score into the latest prediction
|
|
||||||
"""
|
|
||||||
raise NotImplementedError("_update_model is not implemented!")
|
|
||||||
|
|
||||||
|
|
||||||
class RLStrategy(BaseStrategy):
|
class RLStrategy(BaseStrategy):
|
||||||
"""RL-based strategy"""
|
"""RL-based strategy"""
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ def get_callable_kwargs(config: Union[dict, str], default_module: Union[str, Mod
|
|||||||
----------
|
----------
|
||||||
config : [dict, str]
|
config : [dict, str]
|
||||||
similar to config
|
similar to config
|
||||||
|
please refer to the doc of init_instance_by_config
|
||||||
|
|
||||||
default_module : Python module or str
|
default_module : Python module or str
|
||||||
It should be a python module to load the class type
|
It should be a python module to load the class type
|
||||||
@@ -219,9 +220,12 @@ def get_callable_kwargs(config: Union[dict, str], default_module: Union[str, Mod
|
|||||||
_callable = config["class"] # the class type itself is passed in
|
_callable = config["class"] # the class type itself is passed in
|
||||||
kwargs = config.get("kwargs", {})
|
kwargs = config.get("kwargs", {})
|
||||||
elif isinstance(config, str):
|
elif isinstance(config, str):
|
||||||
module = get_module_by_module_path(default_module)
|
# a.b.c.ClassName
|
||||||
|
*m_path, cls = config.split(".")
|
||||||
|
m_path = ".".join(m_path)
|
||||||
|
module = get_module_by_module_path(default_module if m_path == "" else m_path)
|
||||||
|
|
||||||
_callable = getattr(module, config)
|
_callable = getattr(module, cls)
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError(f"This type of input is not supported")
|
raise NotImplementedError(f"This type of input is not supported")
|
||||||
@@ -260,7 +264,9 @@ def init_instance_by_config(
|
|||||||
1) specify a pickle object
|
1) specify a pickle object
|
||||||
- path like 'file:///<path to pickle file>/obj.pkl'
|
- path like 'file:///<path to pickle file>/obj.pkl'
|
||||||
2) specify a class name
|
2) specify a class name
|
||||||
- "ClassName": getattr(module, config)() will be used.
|
- "ClassName": getattr(module, "ClassName")() will be used.
|
||||||
|
3) specify module path with class name
|
||||||
|
- "a.b.c.ClassName" getattr(<a.b.c.module>, "ClassName")() will be used.
|
||||||
object example:
|
object example:
|
||||||
instance of accept_types
|
instance of accept_types
|
||||||
default_module : Python module
|
default_module : Python module
|
||||||
|
|||||||
@@ -401,6 +401,10 @@ class IndexData(metaclass=index_data_ops_creator):
|
|||||||
def columns(self):
|
def columns(self):
|
||||||
return self.indices[1]
|
return self.indices[1]
|
||||||
|
|
||||||
|
def __getitem__(self, args):
|
||||||
|
# NOTE: this tries to behave like a numpy array to be compatible with numpy aggregating function like nansum and nanmean
|
||||||
|
return self.iloc[args]
|
||||||
|
|
||||||
def _align_indices(self, other: "IndexData") -> "IndexData":
|
def _align_indices(self, other: "IndexData") -> "IndexData":
|
||||||
"""
|
"""
|
||||||
Align all indices of `other` to `self` before performing the arithmetic operations.
|
Align all indices of `other` to `self` before performing the arithmetic operations.
|
||||||
@@ -409,7 +413,7 @@ class IndexData(metaclass=index_data_ops_creator):
|
|||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
other : "IndexData"
|
other : "IndexData"
|
||||||
the index in `other` is to be chagned
|
the index in `other` is to be changed
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
@@ -455,7 +459,8 @@ class IndexData(metaclass=index_data_ops_creator):
|
|||||||
"""
|
"""
|
||||||
return len(self.data)
|
return len(self.data)
|
||||||
|
|
||||||
def sum(self, axis=None):
|
def sum(self, axis=None, dtype=None, out=None):
|
||||||
|
assert out is None and dtype is None, "`out` is just for compatible with numpy's aggregating function"
|
||||||
# FIXME: weird logic and not general
|
# FIXME: weird logic and not general
|
||||||
if axis is None:
|
if axis is None:
|
||||||
return np.nansum(self.data)
|
return np.nansum(self.data)
|
||||||
@@ -468,7 +473,8 @@ class IndexData(metaclass=index_data_ops_creator):
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f"axis must be None, 0 or 1")
|
raise ValueError(f"axis must be None, 0 or 1")
|
||||||
|
|
||||||
def mean(self, axis=None):
|
def mean(self, axis=None, dtype=None, out=None):
|
||||||
|
assert out is None and dtype is None, "`out` is just for compatible with numpy's aggregating function"
|
||||||
# FIXME: weird logic and not general
|
# FIXME: weird logic and not general
|
||||||
if axis is None:
|
if axis is None:
|
||||||
return np.nanmean(self.data)
|
return np.nanmean(self.data)
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
# Copyright (c) Microsoft Corporation.
|
# Copyright (c) Microsoft Corporation.
|
||||||
# Licensed under the MIT License.
|
# Licensed under the MIT License.
|
||||||
|
|
||||||
import pandas as pd
|
from functools import partial
|
||||||
|
from threading import Thread
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
from joblib import Parallel, delayed
|
from joblib import Parallel, delayed
|
||||||
from joblib._parallel_backends import MultiprocessingBackend
|
from joblib._parallel_backends import MultiprocessingBackend
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from queue import Queue
|
||||||
|
|
||||||
|
|
||||||
class ParallelExt(Parallel):
|
class ParallelExt(Parallel):
|
||||||
@@ -46,3 +52,54 @@ def datetime_groupby_apply(df, apply_func, axis=0, level="datetime", resample_ru
|
|||||||
return pd.concat(dfs, axis=axis).sort_index()
|
return pd.concat(dfs, axis=axis).sort_index()
|
||||||
else:
|
else:
|
||||||
return _naive_group_apply(df)
|
return _naive_group_apply(df)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncCaller:
|
||||||
|
"""
|
||||||
|
This AsyncCaller tries to make it easier to async call
|
||||||
|
|
||||||
|
Currently, it is used in MLflowRecorder to make functions like `log_params` async
|
||||||
|
|
||||||
|
NOTE:
|
||||||
|
- This caller didn't consider the return value
|
||||||
|
"""
|
||||||
|
|
||||||
|
STOP_MARK = "__STOP"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._q = Queue()
|
||||||
|
self._stop = False
|
||||||
|
self._t = Thread(target=self.run)
|
||||||
|
self._t.start()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._q.put(self.STOP_MARK)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
while True:
|
||||||
|
data = self._q.get()
|
||||||
|
if data == self.STOP_MARK:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
data()
|
||||||
|
|
||||||
|
def __call__(self, func, *args, **kwargs):
|
||||||
|
self._q.put(partial(func, *args, **kwargs))
|
||||||
|
|
||||||
|
def wait(self, close=True):
|
||||||
|
if close:
|
||||||
|
self.close()
|
||||||
|
self._t.join()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def async_dec(ac_attr):
|
||||||
|
def decorator_func(func):
|
||||||
|
def wrapper(self, *args, **kwargs):
|
||||||
|
if isinstance(getattr(self, ac_attr, None), Callable):
|
||||||
|
return getattr(self, ac_attr)(func, self, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
return func(self, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator_func
|
||||||
|
|||||||
@@ -21,19 +21,65 @@ Situations Description
|
|||||||
Online + Trainer When you want to do a REAL routine, the Trainer will help you train the models. It
|
Online + Trainer When you want to do a REAL routine, the Trainer will help you train the models. It
|
||||||
will train models task by task and strategy by strategy.
|
will train models task by task and strategy by strategy.
|
||||||
|
|
||||||
Online + DelayTrainer When your models don't have any temporal dependence, the DelayTrainer will train
|
Online + DelayTrainer DelayTrainer will skip concrete training until all tasks have been prepared by
|
||||||
nothing until all tasks have been prepared. It makes user can train all tasks in
|
different strategies. It makes users can parallelly train all tasks at the end of
|
||||||
the end of `routine` or `first_train`.
|
`routine` or `first_train`. Otherwise, these functions will get stuck when each
|
||||||
|
strategy prepare tasks.
|
||||||
|
|
||||||
Simulation + Trainer When your models have some temporal dependence on the previous models, then you
|
Simulation + Trainer It will behave in the same way as `Online + Trainer`. The only difference is that it
|
||||||
need to consider using Trainer. This means it will REAL train your models in
|
is for simulation/backtesting instead of online trading
|
||||||
every routine and prepare signals for every routine.
|
|
||||||
|
|
||||||
Simulation + DelayTrainer When your models don't have any temporal dependence, you can use DelayTrainer
|
Simulation + DelayTrainer When your models don't have any temporal dependence, you can use DelayTrainer
|
||||||
for the ability to multitasking. It means all tasks in all routines
|
for the ability to multitasking. It means all tasks in all routines
|
||||||
can be REAL trained at the end of simulating. The signals will be prepared well at
|
can be REAL trained at the end of simulating. The signals will be prepared well at
|
||||||
different time segments (based on whether or not any new model is online).
|
different time segments (based on whether or not any new model is online).
|
||||||
========================= ===================================================================================
|
========================= ===================================================================================
|
||||||
|
|
||||||
|
Here is some pseudo code the demonstrate the workflow of each situation
|
||||||
|
|
||||||
|
For simplicity
|
||||||
|
- Only one strategy is used in the strategy
|
||||||
|
- `update_online_pred` is only called in the online mode and is ignored
|
||||||
|
|
||||||
|
1) `Online + Trainer`
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
tasks = first_train()
|
||||||
|
models = trainer.train(tasks)
|
||||||
|
trainer.end_train(models)
|
||||||
|
for day in online_trading_days:
|
||||||
|
# OnlineManager.routine
|
||||||
|
models = trainer.train(strategy.prepare_tasks()) # for each strategy
|
||||||
|
strategy.prepare_online_models(models) # for each strategy
|
||||||
|
|
||||||
|
trainer.end_train(models)
|
||||||
|
prepare_signals() # prepare trading signals daily
|
||||||
|
|
||||||
|
|
||||||
|
`Online + DelayTrainer`: the workflow is the same as `Online + Trainer`.
|
||||||
|
|
||||||
|
|
||||||
|
2) `Simulation + DelayTrainer`
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
# simulate
|
||||||
|
tasks = first_train()
|
||||||
|
models = trainer.train(tasks)
|
||||||
|
for day in historical_calendars:
|
||||||
|
# OnlineManager.routine
|
||||||
|
models = trainer.train(strategy.prepare_tasks()) # for each strategy
|
||||||
|
strategy.prepare_online_models(models) # for each strategy
|
||||||
|
# delay_prepare()
|
||||||
|
# FIXME: Currently the delay_prepare is not implemented in a proper way.
|
||||||
|
trainer.end_train(<for all previous models>)
|
||||||
|
prepare_signals()
|
||||||
|
|
||||||
|
|
||||||
|
# Can we simplify current workflow?
|
||||||
|
- Can reduce the number of state of tasks?
|
||||||
|
- For each task, we have three phases (i.e. task, partly trained task, final trained task)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -58,7 +104,7 @@ class OnlineManager(Serializable):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
STATUS_SIMULATING = "simulating" # when calling `simulate`
|
STATUS_SIMULATING = "simulating" # when calling `simulate`
|
||||||
STATUS_NORMAL = "normal" # the normal status
|
STATUS_ONLINE = "online" # the normal status. It is used when online trading
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -87,12 +133,24 @@ class OnlineManager(Serializable):
|
|||||||
self.begin_time = pd.Timestamp(begin_time)
|
self.begin_time = pd.Timestamp(begin_time)
|
||||||
self.cur_time = self.begin_time
|
self.cur_time = self.begin_time
|
||||||
# OnlineManager will recorder the history of online models, which is a dict like {pd.Timestamp, {strategy, [online_models]}}.
|
# OnlineManager will recorder the history of online models, which is a dict like {pd.Timestamp, {strategy, [online_models]}}.
|
||||||
|
# It records the online servnig models of each strategy for each day.
|
||||||
self.history = {}
|
self.history = {}
|
||||||
if trainer is None:
|
if trainer is None:
|
||||||
trainer = TrainerR()
|
trainer = TrainerR()
|
||||||
self.trainer = trainer
|
self.trainer = trainer
|
||||||
self.signals = None
|
self.signals = None
|
||||||
self.status = self.STATUS_NORMAL
|
self.status = self.STATUS_ONLINE
|
||||||
|
|
||||||
|
def _postpone_action(self):
|
||||||
|
"""
|
||||||
|
Should the workflow to postpone the following actions to the end (in delay_prepare)
|
||||||
|
- trainer.end_train
|
||||||
|
- prepare_signals
|
||||||
|
|
||||||
|
Postpone these actions is to support simulating/backtest online strategies without time dependencies.
|
||||||
|
All the actions can be done parallelly at the end.
|
||||||
|
"""
|
||||||
|
return self.status == self.STATUS_SIMULATING and self.trainer.is_delay()
|
||||||
|
|
||||||
def first_train(self, strategies: List[OnlineStrategy] = None, model_kwargs: dict = {}):
|
def first_train(self, strategies: List[OnlineStrategy] = None, model_kwargs: dict = {}):
|
||||||
"""
|
"""
|
||||||
@@ -113,12 +171,12 @@ class OnlineManager(Serializable):
|
|||||||
models = self.trainer.train(tasks, experiment_name=strategy.name_id)
|
models = self.trainer.train(tasks, experiment_name=strategy.name_id)
|
||||||
models_list.append(models)
|
models_list.append(models)
|
||||||
self.logger.info(f"Finished training {len(models)} models.")
|
self.logger.info(f"Finished training {len(models)} models.")
|
||||||
# FIXME: Traing multiple online models at `first_train` will result in getting too much online models at the
|
# FIXME: Train multiple online models at `first_train` will result in getting too much online models at the
|
||||||
# start.
|
# start.
|
||||||
online_models = strategy.prepare_online_models(models, **model_kwargs)
|
online_models = strategy.prepare_online_models(models, **model_kwargs)
|
||||||
self.history.setdefault(self.cur_time, {})[strategy] = online_models
|
self.history.setdefault(self.cur_time, {})[strategy] = online_models
|
||||||
|
|
||||||
if not self.status == self.STATUS_SIMULATING or not self.trainer.is_delay():
|
if not self._postpone_action():
|
||||||
for strategy, models in zip(strategies, models_list):
|
for strategy, models in zip(strategies, models_list):
|
||||||
models = self.trainer.end_train(models, experiment_name=strategy.name_id)
|
models = self.trainer.end_train(models, experiment_name=strategy.name_id)
|
||||||
|
|
||||||
@@ -160,10 +218,10 @@ class OnlineManager(Serializable):
|
|||||||
|
|
||||||
# The online model may changes in the above processes
|
# The online model may changes in the above processes
|
||||||
# So updating the predictions of online models should be the last step
|
# So updating the predictions of online models should be the last step
|
||||||
if self.status == self.STATUS_NORMAL:
|
if self.status == self.STATUS_ONLINE:
|
||||||
strategy.tool.update_online_pred()
|
strategy.tool.update_online_pred()
|
||||||
|
|
||||||
if not self.status == self.STATUS_SIMULATING or not self.trainer.is_delay():
|
if not self._postpone_action():
|
||||||
for strategy, models in zip(self.strategies, models_list):
|
for strategy, models in zip(self.strategies, models_list):
|
||||||
models = self.trainer.end_train(models, experiment_name=strategy.name_id)
|
models = self.trainer.end_train(models, experiment_name=strategy.name_id)
|
||||||
self.prepare_signals(**signal_kwargs)
|
self.prepare_signals(**signal_kwargs)
|
||||||
@@ -278,13 +336,13 @@ class OnlineManager(Serializable):
|
|||||||
signal_kwargs=signal_kwargs,
|
signal_kwargs=signal_kwargs,
|
||||||
)
|
)
|
||||||
# delay prepare the models and signals
|
# delay prepare the models and signals
|
||||||
if self.trainer.is_delay():
|
if self._postpone_action():
|
||||||
self.delay_prepare(model_kwargs=model_kwargs, signal_kwargs=signal_kwargs)
|
self.delay_prepare(model_kwargs=model_kwargs, signal_kwargs=signal_kwargs)
|
||||||
|
|
||||||
# FIXME: get logging level firstly and restore it here
|
# FIXME: get logging level firstly and restore it here
|
||||||
set_global_logger_level(logging.DEBUG)
|
set_global_logger_level(logging.DEBUG)
|
||||||
self.logger.info(f"Finished preparing signals")
|
self.logger.info(f"Finished preparing signals")
|
||||||
self.status = self.STATUS_NORMAL
|
self.status = self.STATUS_ONLINE
|
||||||
return self.get_signals()
|
return self.get_signals()
|
||||||
|
|
||||||
def delay_prepare(self, model_kwargs={}, signal_kwargs={}):
|
def delay_prepare(self, model_kwargs={}, signal_kwargs={}):
|
||||||
@@ -295,6 +353,8 @@ class OnlineManager(Serializable):
|
|||||||
model_kwargs: the params for `end_train`
|
model_kwargs: the params for `end_train`
|
||||||
signal_kwargs: the params for `prepare_signals`
|
signal_kwargs: the params for `prepare_signals`
|
||||||
"""
|
"""
|
||||||
|
# FIXME:
|
||||||
|
# This method is not implemented in the proper way!!!
|
||||||
last_models = {}
|
last_models = {}
|
||||||
signals_time = D.calendar()[0]
|
signals_time = D.calendar()[0]
|
||||||
need_prepare = False
|
need_prepare = False
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import pandas as pd
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from pprint import pprint
|
from pprint import pprint
|
||||||
from typing import Union, List
|
from typing import Union, List
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from qlib.utils.exceptions import LoadObjectError
|
||||||
from ..contrib.evaluate import indicator_analysis, risk_analysis, indicator_analysis
|
from ..contrib.evaluate import indicator_analysis, risk_analysis, indicator_analysis
|
||||||
|
|
||||||
from ..data.dataset import DatasetH
|
from ..data.dataset import DatasetH
|
||||||
@@ -45,6 +48,16 @@ class RecordTemp:
|
|||||||
|
|
||||||
return "/".join(names)
|
return "/".join(names)
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
"""
|
||||||
|
It behaves the same as self.recorder.save_objects.
|
||||||
|
But it is an easier interface because users don't have to care about `get_path` and `artifact_path`
|
||||||
|
"""
|
||||||
|
art_path = self.get_path()
|
||||||
|
if art_path == "":
|
||||||
|
art_path = None
|
||||||
|
self.recorder.save_objects(artifact_path=art_path, **kwargs)
|
||||||
|
|
||||||
def __init__(self, recorder):
|
def __init__(self, recorder):
|
||||||
self._recorder = recorder
|
self._recorder = recorder
|
||||||
|
|
||||||
@@ -67,31 +80,37 @@ class RecordTemp:
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError(f"Please implement the `generate` method.")
|
raise NotImplementedError(f"Please implement the `generate` method.")
|
||||||
|
|
||||||
def load(self, name):
|
def load(self, name: str, parents: bool = True):
|
||||||
"""
|
"""
|
||||||
Load the stored records. Due to the fact that some problems occured when we tried to balancing a clean API
|
It behaves the same as self.recorder.load_object.
|
||||||
with the Python's inheritance. This method has to be used in a rather ugly way, and we will try to fix them
|
But it is an easier interface because users don't have to care about `get_path` and `artifact_path`
|
||||||
in the future::
|
|
||||||
|
|
||||||
sar = SigAnaRecord(recorder)
|
|
||||||
ic = sar.load(sar.get_path("ic.pkl"))
|
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
name : str
|
name : str
|
||||||
the name for the file to be load.
|
the name for the file to be load.
|
||||||
|
|
||||||
|
parents : bool
|
||||||
|
Each recorder has different `artifact_path`.
|
||||||
|
So parents recursively find the path in parents
|
||||||
|
Sub classes has higher priority
|
||||||
|
|
||||||
Return
|
Return
|
||||||
------
|
------
|
||||||
The stored records.
|
The stored records.
|
||||||
"""
|
"""
|
||||||
# try to load the saved object
|
try:
|
||||||
obj = self.recorder.load_object(name)
|
return self.recorder.load_object(self.get_path(name))
|
||||||
return obj
|
except LoadObjectError:
|
||||||
|
if parents:
|
||||||
|
if self.depend_cls is not None:
|
||||||
|
with class_casting(self, self.depend_cls):
|
||||||
|
return self.load(name, parents=True)
|
||||||
|
|
||||||
def list(self):
|
def list(self):
|
||||||
"""
|
"""
|
||||||
List the supported artifacts.
|
List the supported artifacts.
|
||||||
|
Users don't have to consider self.get_path
|
||||||
|
|
||||||
Return
|
Return
|
||||||
------
|
------
|
||||||
@@ -99,7 +118,7 @@ class RecordTemp:
|
|||||||
"""
|
"""
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def check(self, include_self: bool = False):
|
def check(self, include_self: bool = False, parents: bool = True):
|
||||||
"""
|
"""
|
||||||
Check if the records is properly generated and saved.
|
Check if the records is properly generated and saved.
|
||||||
It is useful in following examples
|
It is useful in following examples
|
||||||
@@ -110,19 +129,34 @@ class RecordTemp:
|
|||||||
----------
|
----------
|
||||||
include_self : bool
|
include_self : bool
|
||||||
is the file generated by self included
|
is the file generated by self included
|
||||||
|
parents : bool
|
||||||
|
will we check parents
|
||||||
|
|
||||||
Raise
|
Raise
|
||||||
------
|
------
|
||||||
FileExistsError: whether the records are stored properly.
|
FileNotFoundError
|
||||||
|
: whether the records are stored properly.
|
||||||
"""
|
"""
|
||||||
artifacts = set(self.recorder.list_artifacts())
|
|
||||||
if include_self:
|
if include_self:
|
||||||
|
|
||||||
|
# Some mlflow backend will not list the directly recursively.
|
||||||
|
# So we force to the directly
|
||||||
|
artifacts = {}
|
||||||
|
|
||||||
|
def _get_arts(dirn):
|
||||||
|
if dirn not in artifacts:
|
||||||
|
artifacts[dirn] = self.recorder.list_artifacts(dirn)
|
||||||
|
return artifacts[dirn]
|
||||||
|
|
||||||
for item in self.list():
|
for item in self.list():
|
||||||
if item not in artifacts:
|
ps = self.get_path(item).split("/")
|
||||||
raise FileExistsError(item)
|
dirn, fn = "/".join(ps[:-1]), ps[-1]
|
||||||
if self.depend_cls is not None:
|
if self.get_path(item) not in _get_arts(dirn):
|
||||||
with class_casting(self, self.depend_cls):
|
raise FileNotFoundError
|
||||||
self.check(include_self=True)
|
if parents:
|
||||||
|
if self.depend_cls is not None:
|
||||||
|
with class_casting(self, self.depend_cls):
|
||||||
|
self.check(include_self=True)
|
||||||
|
|
||||||
|
|
||||||
class SignalRecord(RecordTemp):
|
class SignalRecord(RecordTemp):
|
||||||
@@ -158,7 +192,7 @@ class SignalRecord(RecordTemp):
|
|||||||
pred = self.model.predict(self.dataset)
|
pred = self.model.predict(self.dataset)
|
||||||
if isinstance(pred, pd.Series):
|
if isinstance(pred, pd.Series):
|
||||||
pred = pred.to_frame("score")
|
pred = pred.to_frame("score")
|
||||||
self.recorder.save_objects(**{"pred.pkl": pred})
|
self.save(**{"pred.pkl": pred})
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Signal record 'pred.pkl' has been saved as the artifact of the Experiment {self.recorder.experiment_id}"
|
f"Signal record 'pred.pkl' has been saved as the artifact of the Experiment {self.recorder.experiment_id}"
|
||||||
@@ -169,15 +203,11 @@ class SignalRecord(RecordTemp):
|
|||||||
|
|
||||||
if isinstance(self.dataset, DatasetH):
|
if isinstance(self.dataset, DatasetH):
|
||||||
raw_label = self.generate_label(self.dataset)
|
raw_label = self.generate_label(self.dataset)
|
||||||
self.recorder.save_objects(**{"label.pkl": raw_label})
|
self.save(**{"label.pkl": raw_label})
|
||||||
|
|
||||||
@staticmethod
|
def list(self):
|
||||||
def list():
|
|
||||||
return ["pred.pkl", "label.pkl"]
|
return ["pred.pkl", "label.pkl"]
|
||||||
|
|
||||||
def load(self, name="pred.pkl"):
|
|
||||||
return super().load(name)
|
|
||||||
|
|
||||||
|
|
||||||
class HFSignalRecord(SignalRecord):
|
class HFSignalRecord(SignalRecord):
|
||||||
"""
|
"""
|
||||||
@@ -218,19 +248,11 @@ class HFSignalRecord(SignalRecord):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.recorder.log_metrics(**metrics)
|
self.recorder.log_metrics(**metrics)
|
||||||
self.recorder.save_objects(**objects, artifact_path=self.get_path())
|
self.save(**objects)
|
||||||
pprint(metrics)
|
pprint(metrics)
|
||||||
|
|
||||||
def list(self):
|
def list(self):
|
||||||
paths = [
|
return ["ic.pkl", "ric.pkl", "long_pre.pkl", "short_pre.pkl", "long_short_r.pkl", "long_avg_r.pkl"]
|
||||||
self.get_path("ic.pkl"),
|
|
||||||
self.get_path("ric.pkl"),
|
|
||||||
self.get_path("long_pre.pkl"),
|
|
||||||
self.get_path("short_pre.pkl"),
|
|
||||||
self.get_path("long_short_r.pkl"),
|
|
||||||
self.get_path("long_avg_r.pkl"),
|
|
||||||
]
|
|
||||||
return paths
|
|
||||||
|
|
||||||
|
|
||||||
class SigAnaRecord(RecordTemp):
|
class SigAnaRecord(RecordTemp):
|
||||||
@@ -241,13 +263,23 @@ class SigAnaRecord(RecordTemp):
|
|||||||
artifact_path = "sig_analysis"
|
artifact_path = "sig_analysis"
|
||||||
depend_cls = SignalRecord
|
depend_cls = SignalRecord
|
||||||
|
|
||||||
def __init__(self, recorder, ana_long_short=False, ann_scaler=252, label_col=0):
|
def __init__(self, recorder, ana_long_short=False, ann_scaler=252, label_col=0, skip_existing=False):
|
||||||
super().__init__(recorder=recorder)
|
super().__init__(recorder=recorder)
|
||||||
self.ana_long_short = ana_long_short
|
self.ana_long_short = ana_long_short
|
||||||
self.ann_scaler = ann_scaler
|
self.ann_scaler = ann_scaler
|
||||||
self.label_col = label_col
|
self.label_col = label_col
|
||||||
|
self.skip_existing = skip_existing
|
||||||
|
|
||||||
def generate(self, **kwargs):
|
def generate(self, **kwargs):
|
||||||
|
if self.skip_existing:
|
||||||
|
try:
|
||||||
|
self.check(include_self=True, parents=False)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass # continue to generating metrics
|
||||||
|
else:
|
||||||
|
logger.info("The results has previously generated, generation skipped.")
|
||||||
|
return
|
||||||
|
|
||||||
self.check()
|
self.check()
|
||||||
|
|
||||||
pred = self.load("pred.pkl")
|
pred = self.load("pred.pkl")
|
||||||
@@ -280,13 +312,13 @@ class SigAnaRecord(RecordTemp):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.recorder.log_metrics(**metrics)
|
self.recorder.log_metrics(**metrics)
|
||||||
self.recorder.save_objects(**objects, artifact_path=self.get_path())
|
self.save(**objects)
|
||||||
pprint(metrics)
|
pprint(metrics)
|
||||||
|
|
||||||
def list(self):
|
def list(self):
|
||||||
paths = [self.get_path("ic.pkl"), self.get_path("ric.pkl")]
|
paths = ["ic.pkl", "ric.pkl"]
|
||||||
if self.ana_long_short:
|
if self.ana_long_short:
|
||||||
paths.extend([self.get_path("long_short_r.pkl"), self.get_path("long_avg_r.pkl")])
|
paths.extend(["long_short_r.pkl", "long_avg_r.pkl"])
|
||||||
return paths
|
return paths
|
||||||
|
|
||||||
|
|
||||||
@@ -373,17 +405,11 @@ class PortAnaRecord(RecordTemp):
|
|||||||
executor=self.executor_config, strategy=self.strategy_config, **self.backtest_config
|
executor=self.executor_config, strategy=self.strategy_config, **self.backtest_config
|
||||||
)
|
)
|
||||||
for _freq, (report_normal, positions_normal) in portfolio_metric_dict.items():
|
for _freq, (report_normal, positions_normal) in portfolio_metric_dict.items():
|
||||||
self.recorder.save_objects(
|
self.save(**{f"report_normal_{_freq}.pkl": report_normal})
|
||||||
**{f"report_normal_{_freq}.pkl": report_normal}, artifact_path=PortAnaRecord.get_path()
|
self.save(**{f"positions_normal_{_freq}.pkl": positions_normal})
|
||||||
)
|
|
||||||
self.recorder.save_objects(
|
|
||||||
**{f"positions_normal_{_freq}.pkl": positions_normal}, artifact_path=PortAnaRecord.get_path()
|
|
||||||
)
|
|
||||||
|
|
||||||
for _freq, indicators_normal in indicator_dict.items():
|
for _freq, indicators_normal in indicator_dict.items():
|
||||||
self.recorder.save_objects(
|
self.save(**{f"indicators_normal_{_freq}.pkl": indicators_normal})
|
||||||
**{f"indicators_normal_{_freq}.pkl": indicators_normal}, artifact_path=PortAnaRecord.get_path()
|
|
||||||
)
|
|
||||||
|
|
||||||
for _analysis_freq in self.risk_analysis_freq:
|
for _analysis_freq in self.risk_analysis_freq:
|
||||||
if _analysis_freq not in portfolio_metric_dict:
|
if _analysis_freq not in portfolio_metric_dict:
|
||||||
@@ -405,9 +431,7 @@ class PortAnaRecord(RecordTemp):
|
|||||||
analysis_dict = flatten_dict(analysis_df["risk"].unstack().T.to_dict())
|
analysis_dict = flatten_dict(analysis_df["risk"].unstack().T.to_dict())
|
||||||
self.recorder.log_metrics(**{f"{_analysis_freq}.{k}": v for k, v in analysis_dict.items()})
|
self.recorder.log_metrics(**{f"{_analysis_freq}.{k}": v for k, v in analysis_dict.items()})
|
||||||
# save results
|
# save results
|
||||||
self.recorder.save_objects(
|
self.save(**{f"port_analysis_{_analysis_freq}.pkl": analysis_df})
|
||||||
**{f"port_analysis_{_analysis_freq}.pkl": analysis_df}, artifact_path=PortAnaRecord.get_path()
|
|
||||||
)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Portfolio analysis record 'port_analysis_{_analysis_freq}.pkl' has been saved as the artifact of the Experiment {self.recorder.experiment_id}"
|
f"Portfolio analysis record 'port_analysis_{_analysis_freq}.pkl' has been saved as the artifact of the Experiment {self.recorder.experiment_id}"
|
||||||
)
|
)
|
||||||
@@ -432,9 +456,7 @@ class PortAnaRecord(RecordTemp):
|
|||||||
analysis_dict = analysis_df["value"].to_dict()
|
analysis_dict = analysis_df["value"].to_dict()
|
||||||
self.recorder.log_metrics(**{f"{_analysis_freq}.{k}": v for k, v in analysis_dict.items()})
|
self.recorder.log_metrics(**{f"{_analysis_freq}.{k}": v for k, v in analysis_dict.items()})
|
||||||
# save results
|
# save results
|
||||||
self.recorder.save_objects(
|
self.save(**{f"indicator_analysis_{_analysis_freq}.pkl": analysis_df})
|
||||||
**{f"indicator_analysis_{_analysis_freq}.pkl": analysis_df}, artifact_path=PortAnaRecord.get_path()
|
|
||||||
)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Indicator analysis record 'indicator_analysis_{_analysis_freq}.pkl' has been saved as the artifact of the Experiment {self.recorder.experiment_id}"
|
f"Indicator analysis record 'indicator_analysis_{_analysis_freq}.pkl' has been saved as the artifact of the Experiment {self.recorder.experiment_id}"
|
||||||
)
|
)
|
||||||
@@ -446,20 +468,19 @@ class PortAnaRecord(RecordTemp):
|
|||||||
for _freq in self.all_freq:
|
for _freq in self.all_freq:
|
||||||
list_path.extend(
|
list_path.extend(
|
||||||
[
|
[
|
||||||
PortAnaRecord.get_path(f"report_normal_{_freq}.pkl"),
|
f"report_normal_{_freq}.pkl",
|
||||||
PortAnaRecord.get_path(f"positions_normal_{_freq}.pkl"),
|
f"positions_normal_{_freq}.pkl",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
for _analysis_freq in self.risk_analysis_freq:
|
for _analysis_freq in self.risk_analysis_freq:
|
||||||
if _analysis_freq in self.all_freq:
|
if _analysis_freq in self.all_freq:
|
||||||
list_path.append(PortAnaRecord.get_path(f"port_analysis_{_analysis_freq}.pkl"))
|
list_path.append(f"port_analysis_{_analysis_freq}.pkl")
|
||||||
else:
|
else:
|
||||||
warnings.warn(f"risk_analysis freq {_analysis_freq} is not found")
|
warnings.warn(f"risk_analysis freq {_analysis_freq} is not found")
|
||||||
|
|
||||||
for _analysis_freq in self.indicator_analysis_freq:
|
for _analysis_freq in self.indicator_analysis_freq:
|
||||||
if _analysis_freq in self.all_freq:
|
if _analysis_freq in self.all_freq:
|
||||||
list_path.append(PortAnaRecord.get_path(f"indicator_analysis_{_analysis_freq}.pkl"))
|
list_path.append(f"indicator_analysis_{_analysis_freq}.pkl")
|
||||||
else:
|
else:
|
||||||
warnings.warn(f"indicator_analysis freq {_analysis_freq} is not found")
|
warnings.warn(f"indicator_analysis freq {_analysis_freq} is not found")
|
||||||
|
|
||||||
return list_path
|
return list_path
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Copyright (c) Microsoft Corporation.
|
# Copyright (c) Microsoft Corporation.
|
||||||
# Licensed under the MIT License.
|
# Licensed under the MIT License.
|
||||||
|
|
||||||
|
import os
|
||||||
from qlib.utils.serial import Serializable
|
from qlib.utils.serial import Serializable
|
||||||
import mlflow, logging
|
import mlflow, logging
|
||||||
import shutil, os, pickle, tempfile, codecs, pickle
|
import shutil, os, pickle, tempfile, codecs, pickle
|
||||||
@@ -8,8 +9,10 @@ from pathlib import Path
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from qlib.utils.exceptions import LoadObjectError
|
from qlib.utils.exceptions import LoadObjectError
|
||||||
|
from qlib.utils.paral import AsyncCaller
|
||||||
from ..utils.objm import FileManager
|
from ..utils.objm import FileManager
|
||||||
from ..log import get_module_logger
|
from ..log import TimeInspector, get_module_logger
|
||||||
|
from mlflow.store.artifact.azure_blob_artifact_repo import AzureBlobArtifactRepository
|
||||||
|
|
||||||
logger = get_module_logger("workflow", logging.INFO)
|
logger = get_module_logger("workflow", logging.INFO)
|
||||||
|
|
||||||
@@ -227,6 +230,7 @@ class MLflowRecorder(Recorder):
|
|||||||
if mlflow_run.info.end_time is not None
|
if mlflow_run.info.end_time is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
self.async_log = None
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
name = self.__class__.__name__
|
name = self.__class__.__name__
|
||||||
@@ -285,6 +289,10 @@ class MLflowRecorder(Recorder):
|
|||||||
self.status = Recorder.STATUS_R
|
self.status = Recorder.STATUS_R
|
||||||
logger.info(f"Recorder {self.id} starts running under Experiment {self.experiment_id} ...")
|
logger.info(f"Recorder {self.id} starts running under Experiment {self.experiment_id} ...")
|
||||||
|
|
||||||
|
# NOTE: making logging async.
|
||||||
|
# - This may cause delay when uploading results
|
||||||
|
# - The logging time may not be accurate
|
||||||
|
self.async_log = AsyncCaller()
|
||||||
return run
|
return run
|
||||||
|
|
||||||
def end_run(self, status: str = Recorder.STATUS_S):
|
def end_run(self, status: str = Recorder.STATUS_S):
|
||||||
@@ -298,6 +306,9 @@ class MLflowRecorder(Recorder):
|
|||||||
self.end_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
self.end_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
if self.status != Recorder.STATUS_S:
|
if self.status != Recorder.STATUS_S:
|
||||||
self.status = status
|
self.status = status
|
||||||
|
with TimeInspector.logt("waiting `async_log`"):
|
||||||
|
self.async_log.wait()
|
||||||
|
self.async_log = None
|
||||||
|
|
||||||
def save_objects(self, local_path=None, artifact_path=None, **kwargs):
|
def save_objects(self, local_path=None, artifact_path=None, **kwargs):
|
||||||
assert self.uri is not None, "Please start the experiment and recorder first before using recorder directly."
|
assert self.uri is not None, "Please start the experiment and recorder first before using recorder directly."
|
||||||
@@ -333,18 +344,27 @@ class MLflowRecorder(Recorder):
|
|||||||
try:
|
try:
|
||||||
path = self.client.download_artifacts(self.id, name)
|
path = self.client.download_artifacts(self.id, name)
|
||||||
with Path(path).open("rb") as f:
|
with Path(path).open("rb") as f:
|
||||||
return pickle.load(f)
|
data = pickle.load(f)
|
||||||
|
ar = self.client._tracking_client._get_artifact_repo(self.id)
|
||||||
|
if isinstance(ar, AzureBlobArtifactRepository):
|
||||||
|
# for saving disk space
|
||||||
|
# For safety, only remove redundant file for specific ArtifactRepository
|
||||||
|
shutil.rmtree(Path(path).absolute().parent)
|
||||||
|
return data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise LoadObjectError(message=str(e))
|
raise LoadObjectError(message=str(e))
|
||||||
|
|
||||||
|
@AsyncCaller.async_dec(ac_attr="async_log")
|
||||||
def log_params(self, **kwargs):
|
def log_params(self, **kwargs):
|
||||||
for name, data in kwargs.items():
|
for name, data in kwargs.items():
|
||||||
self.client.log_param(self.id, name, data)
|
self.client.log_param(self.id, name, data)
|
||||||
|
|
||||||
|
@AsyncCaller.async_dec(ac_attr="async_log")
|
||||||
def log_metrics(self, step=None, **kwargs):
|
def log_metrics(self, step=None, **kwargs):
|
||||||
for name, data in kwargs.items():
|
for name, data in kwargs.items():
|
||||||
self.client.log_metric(self.id, name, data, step=step)
|
self.client.log_metric(self.id, name, data, step=step)
|
||||||
|
|
||||||
|
@AsyncCaller.async_dec(ac_attr="async_log")
|
||||||
def set_tags(self, **kwargs):
|
def set_tags(self, **kwargs):
|
||||||
for name, data in kwargs.items():
|
for name, data in kwargs.items():
|
||||||
self.client.set_tag(self.id, name, data)
|
self.client.set_tag(self.id, name, data)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
Collector module can collect objects from everywhere and process them such as merging, grouping, averaging and so on.
|
Collector module can collect objects from everywhere and process them such as merging, grouping, averaging and so on.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from libs.qlib.qlib.log import TimeInspector
|
||||||
from typing import Callable, Dict, List
|
from typing import Callable, Dict, List
|
||||||
from qlib.log import get_module_logger
|
from qlib.log import get_module_logger
|
||||||
from qlib.utils.serial import Serializable
|
from qlib.utils.serial import Serializable
|
||||||
@@ -190,7 +191,9 @@ class RecorderCollector(Collector):
|
|||||||
|
|
||||||
collect_dict = {}
|
collect_dict = {}
|
||||||
# filter records
|
# filter records
|
||||||
recs = self.experiment.list_recorders(**self.list_kwargs)
|
|
||||||
|
with TimeInspector.logt("Time to `list_recorders` in RecorderCollector"):
|
||||||
|
recs = self.experiment.list_recorders(**self.list_kwargs)
|
||||||
recs_flt = {}
|
recs_flt = {}
|
||||||
for rid, rec in recs.items():
|
for rid, rec in recs.items():
|
||||||
if rec_filter_func is None or rec_filter_func(rec):
|
if rec_filter_func is None or rec_filter_func(rec):
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ class TaskGen(metaclass=abc.ABCMeta):
|
|||||||
def handler_mod(task: dict, rolling_gen):
|
def handler_mod(task: dict, rolling_gen):
|
||||||
"""
|
"""
|
||||||
Help to modify the handler end time when using RollingGen
|
Help to modify the handler end time when using RollingGen
|
||||||
|
It try to handle the following case
|
||||||
|
- Hander's data end_time is earlier than dataset's test_data's segments.
|
||||||
|
- To handle this, handler's data's end_time is extended.
|
||||||
|
|
||||||
|
If the handler's end_time is None, then it is not necessary to change it's end time.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task (dict): a task template
|
task (dict): a task template
|
||||||
@@ -112,6 +117,9 @@ def handler_mod(task: dict, rolling_gen):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
# Maybe dataset do not have handler, then do nothing.
|
# Maybe dataset do not have handler, then do nothing.
|
||||||
pass
|
pass
|
||||||
|
except TypeError:
|
||||||
|
# May be the handler is a string. `"handler.pkl"["kwargs"]` will raise TypeError
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class RollingGen(TaskGen):
|
class RollingGen(TaskGen):
|
||||||
@@ -259,3 +267,56 @@ class RollingGen(TaskGen):
|
|||||||
# Update the following rolling
|
# Update the following rolling
|
||||||
res.extend(self.gen_following_tasks(t, test_end))
|
res.extend(self.gen_following_tasks(t, test_end))
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
class MultiHorizonGenBase(TaskGen):
|
||||||
|
def __init__(self, horizon: List[int] = [5], label_leak_n=2):
|
||||||
|
"""
|
||||||
|
This task generator tries to genrate tasks for different horizons based on an existing task
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
horizon : List[int]
|
||||||
|
the possible horizons of the tasks
|
||||||
|
label_leak_n : int
|
||||||
|
How many future days it will take to get complete label after the day making prediction
|
||||||
|
For example:
|
||||||
|
- User make prediction on day `T`(after getting the close price on `T`)
|
||||||
|
- The label is the return of buying stock on `T + 1` and selling it on `T + 2`
|
||||||
|
- the `label_leak_n` will be 2 (e.g. two days of information is leaked to leverage this sample)
|
||||||
|
"""
|
||||||
|
self.horizon = list(horizon)
|
||||||
|
self.label_leak_n = label_leak_n
|
||||||
|
self.ta = TimeAdjuster()
|
||||||
|
self.test_key = "test"
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def set_horizon(self, task: dict, hr: int):
|
||||||
|
"""
|
||||||
|
This method is designed to change the task **in place**
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
task : dict
|
||||||
|
Qlib's task
|
||||||
|
hr : int
|
||||||
|
the horizon of task
|
||||||
|
"""
|
||||||
|
|
||||||
|
def generate(self, task: dict):
|
||||||
|
res = []
|
||||||
|
for hr in self.horizon:
|
||||||
|
|
||||||
|
# Add horizon
|
||||||
|
t = copy.deepcopy(task)
|
||||||
|
self.set_horizon(t, hr)
|
||||||
|
|
||||||
|
# adjust segment
|
||||||
|
segments = self.ta.align_seg(t["dataset"]["kwargs"]["segments"])
|
||||||
|
test_start = min(t for t in segments[self.test_key] if t is not None)
|
||||||
|
for k in list(segments.keys()):
|
||||||
|
if k != self.test_key:
|
||||||
|
segments[k] = self.ta.truncate(segments[k], test_start, hr + self.label_leak_n)
|
||||||
|
t["dataset"]["kwargs"]["segments"] = segments
|
||||||
|
res.append(t)
|
||||||
|
return res
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
import qlib.utils.index_data as idd
|
import qlib.utils.index_data as idd
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
@@ -115,6 +114,19 @@ class IndexDataTest(unittest.TestCase):
|
|||||||
# sd2 = idd.SingleData([1, 2, 3, 4], index=["foo", "bar", "f", "g"])
|
# sd2 = idd.SingleData([1, 2, 3, 4], index=["foo", "bar", "f", "g"])
|
||||||
# 2 * sd2
|
# 2 * sd2
|
||||||
|
|
||||||
|
def test_squeeze(self):
|
||||||
|
sd1 = idd.SingleData([1, 2, 3, 4], index=["foo", "bar", "f", "g"])
|
||||||
|
# automatically squeezing
|
||||||
|
self.assertTrue(not isinstance(np.nansum(sd1), idd.IndexData))
|
||||||
|
self.assertTrue(not isinstance(np.sum(sd1), idd.IndexData))
|
||||||
|
self.assertTrue(not isinstance(sd1.sum(), idd.IndexData))
|
||||||
|
self.assertEqual(np.nansum(sd1), 10)
|
||||||
|
self.assertEqual(np.sum(sd1), 10)
|
||||||
|
self.assertEqual(sd1.sum(), 10)
|
||||||
|
self.assertEqual(np.nanmean(sd1), 2.5)
|
||||||
|
self.assertEqual(np.mean(sd1), 2.5)
|
||||||
|
self.assertEqual(sd1.mean(), 2.5)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -47,13 +47,13 @@ def train(uri_path: str = None):
|
|||||||
rid = recorder.id
|
rid = recorder.id
|
||||||
sr = SignalRecord(model, dataset, recorder)
|
sr = SignalRecord(model, dataset, recorder)
|
||||||
sr.generate()
|
sr.generate()
|
||||||
pred_score = sr.load(sr.get_path("pred.pkl"))
|
pred_score = sr.load("pred.pkl")
|
||||||
|
|
||||||
# calculate ic and ric
|
# calculate ic and ric
|
||||||
sar = SigAnaRecord(recorder)
|
sar = SigAnaRecord(recorder)
|
||||||
sar.generate()
|
sar.generate()
|
||||||
ic = sar.load(sar.get_path("ic.pkl"))
|
ic = sar.load("ic.pkl")
|
||||||
ric = sar.load(sar.get_path("ric.pkl"))
|
ric = sar.load("ric.pkl")
|
||||||
|
|
||||||
return pred_score, {"ic": ic, "ric": ric}, rid
|
return pred_score, {"ic": ic, "ric": ric}, rid
|
||||||
|
|
||||||
@@ -78,13 +78,13 @@ def train_with_sigana(uri_path: str = None):
|
|||||||
|
|
||||||
sr = SignalRecord(model, dataset, recorder)
|
sr = SignalRecord(model, dataset, recorder)
|
||||||
sr.generate()
|
sr.generate()
|
||||||
pred_score = sr.load(sr.get_path("pred.pkl"))
|
pred_score = sr.load("pred.pkl")
|
||||||
|
|
||||||
# predict and calculate ic and ric
|
# predict and calculate ic and ric
|
||||||
sar = SigAnaRecord(recorder)
|
sar = SigAnaRecord(recorder)
|
||||||
sar.generate()
|
sar.generate()
|
||||||
ic = sar.load(sar.get_path("ic.pkl"))
|
ic = sar.load("ic.pkl")
|
||||||
ric = sar.load(sar.get_path("ric.pkl"))
|
ric = sar.load("ric.pkl")
|
||||||
|
|
||||||
uri_path = R.get_uri()
|
uri_path = R.get_uri()
|
||||||
return pred_score, {"ic": ic, "ric": ric}, uri_path
|
return pred_score, {"ic": ic, "ric": ric}, uri_path
|
||||||
@@ -144,10 +144,9 @@ def backtest_analysis(pred, rid, uri_path: str = None):
|
|||||||
},
|
},
|
||||||
"strategy": {
|
"strategy": {
|
||||||
"class": "TopkDropoutStrategy",
|
"class": "TopkDropoutStrategy",
|
||||||
"module_path": "qlib.contrib.strategy.model_strategy",
|
"module_path": "qlib.contrib.strategy.signal_strategy",
|
||||||
"kwargs": {
|
"kwargs": {
|
||||||
"model": model,
|
"signal": (model, dataset),
|
||||||
"dataset": dataset,
|
|
||||||
"topk": 50,
|
"topk": 50,
|
||||||
"n_drop": 5,
|
"n_drop": 5,
|
||||||
},
|
},
|
||||||
@@ -170,7 +169,7 @@ def backtest_analysis(pred, rid, uri_path: str = None):
|
|||||||
# backtest
|
# backtest
|
||||||
par = PortAnaRecord(recorder, port_analysis_config, risk_analysis_freq="day")
|
par = PortAnaRecord(recorder, port_analysis_config, risk_analysis_freq="day")
|
||||||
par.generate()
|
par.generate()
|
||||||
analysis_df = par.load(par.get_path("port_analysis_1day.pkl"))
|
analysis_df = par.load("port_analysis_1day.pkl")
|
||||||
print(analysis_df)
|
print(analysis_df)
|
||||||
return analysis_df
|
return analysis_df
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user